diff --git a/.gitignore b/.gitignore index 54de29ab0..4b1be08d0 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ packages/api/dist packages/responses/dist packages/sncrypto-common/dist packages/sncrypto-web/dist +packages/snjs/dist **/.pnp.* **/.yarn/* diff --git a/.yarn/cache/@mrmlnc-readdir-enhanced-npm-2.2.1-5286808663-d3b82b2936.zip b/.yarn/cache/@mrmlnc-readdir-enhanced-npm-2.2.1-5286808663-d3b82b2936.zip new file mode 100644 index 000000000..71770e76a Binary files /dev/null and b/.yarn/cache/@mrmlnc-readdir-enhanced-npm-2.2.1-5286808663-d3b82b2936.zip differ diff --git a/.yarn/cache/@nodelib-fs.stat-npm-1.1.3-95bc1892a0-318deab369.zip b/.yarn/cache/@nodelib-fs.stat-npm-1.1.3-95bc1892a0-318deab369.zip new file mode 100644 index 000000000..4c68ef6d9 Binary files /dev/null and b/.yarn/cache/@nodelib-fs.stat-npm-1.1.3-95bc1892a0-318deab369.zip differ diff --git a/.yarn/cache/@sinonjs-samsam-npm-6.1.1-06e18132d0-a09b0914bf.zip b/.yarn/cache/@sinonjs-samsam-npm-6.1.1-06e18132d0-a09b0914bf.zip new file mode 100644 index 000000000..3188e3d12 Binary files /dev/null and b/.yarn/cache/@sinonjs-samsam-npm-6.1.1-06e18132d0-a09b0914bf.zip differ diff --git a/.yarn/cache/@sinonjs-text-encoding-npm-0.7.1-865b0079b5-130de0bb56.zip b/.yarn/cache/@sinonjs-text-encoding-npm-0.7.1-865b0079b5-130de0bb56.zip new file mode 100644 index 000000000..b9b97e01e Binary files /dev/null and b/.yarn/cache/@sinonjs-text-encoding-npm-0.7.1-865b0079b5-130de0bb56.zip differ diff --git a/.yarn/cache/@standardnotes-domain-events-npm-2.32.6-62ab814dfe-1c5852e776.zip b/.yarn/cache/@standardnotes-domain-events-npm-2.32.6-62ab814dfe-1c5852e776.zip deleted file mode 100644 index 9c3ec3d99..000000000 Binary files a/.yarn/cache/@standardnotes-domain-events-npm-2.32.6-62ab814dfe-1c5852e776.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-domain-events-npm-2.39.0-ee14411bb8-7a37b281ef.zip b/.yarn/cache/@standardnotes-domain-events-npm-2.39.0-ee14411bb8-7a37b281ef.zip new file mode 100644 index 000000000..639485d5a Binary files /dev/null and b/.yarn/cache/@standardnotes-domain-events-npm-2.39.0-ee14411bb8-7a37b281ef.zip differ diff --git a/.yarn/cache/@standardnotes-predicates-npm-1.2.0-8a42210bdb-779a3fcf93.zip b/.yarn/cache/@standardnotes-predicates-npm-1.2.0-8a42210bdb-779a3fcf93.zip new file mode 100644 index 000000000..f0f25728a Binary files /dev/null and b/.yarn/cache/@standardnotes-predicates-npm-1.2.0-8a42210bdb-779a3fcf93.zip differ diff --git a/.yarn/cache/@standardnotes-scheduler-npm-1.1.2-78f89314fb-68642a0874.zip b/.yarn/cache/@standardnotes-scheduler-npm-1.1.2-78f89314fb-68642a0874.zip deleted file mode 100644 index e06738dc8..000000000 Binary files a/.yarn/cache/@standardnotes-scheduler-npm-1.1.2-78f89314fb-68642a0874.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-settings-npm-1.15.0-bfec86ee49-4397d453a1.zip b/.yarn/cache/@standardnotes-settings-npm-1.15.0-bfec86ee49-4397d453a1.zip deleted file mode 100644 index c5ee2aa65..000000000 Binary files a/.yarn/cache/@standardnotes-settings-npm-1.15.0-bfec86ee49-4397d453a1.zip and /dev/null differ diff --git a/.yarn/cache/@standardnotes-settings-npm-1.17.0-fc942a7080-b376015038.zip b/.yarn/cache/@standardnotes-settings-npm-1.17.0-fc942a7080-b376015038.zip new file mode 100644 index 000000000..b077bb986 Binary files /dev/null and b/.yarn/cache/@standardnotes-settings-npm-1.17.0-fc942a7080-b376015038.zip differ diff --git a/.yarn/cache/@standardnotes-snjs-npm-2.118.3-cf48c13619-96219fce6c.zip b/.yarn/cache/@standardnotes-snjs-npm-2.118.3-cf48c13619-96219fce6c.zip deleted file mode 100644 index 447d49dcf..000000000 Binary files a/.yarn/cache/@standardnotes-snjs-npm-2.118.3-cf48c13619-96219fce6c.zip and /dev/null differ diff --git a/.yarn/cache/@types-crypto-js-npm-4.1.1-1c4ce3312a-ea3d6a67b6.zip b/.yarn/cache/@types-crypto-js-npm-4.1.1-1c4ce3312a-ea3d6a67b6.zip new file mode 100644 index 000000000..c4fed142f Binary files /dev/null and b/.yarn/cache/@types-crypto-js-npm-4.1.1-1c4ce3312a-ea3d6a67b6.zip differ diff --git a/.yarn/cache/@ungap-promise-all-settled-npm-1.1.2-c0f42e147b-08d37fdfa2.zip b/.yarn/cache/@ungap-promise-all-settled-npm-1.1.2-c0f42e147b-08d37fdfa2.zip new file mode 100644 index 000000000..074ceb3e0 Binary files /dev/null and b/.yarn/cache/@ungap-promise-all-settled-npm-1.1.2-c0f42e147b-08d37fdfa2.zip differ diff --git a/.yarn/cache/ansi-colors-npm-4.1.1-97ad42f223-138d04a510.zip b/.yarn/cache/ansi-colors-npm-4.1.1-97ad42f223-138d04a510.zip new file mode 100644 index 000000000..19c6d99a7 Binary files /dev/null and b/.yarn/cache/ansi-colors-npm-4.1.1-97ad42f223-138d04a510.zip differ diff --git a/.yarn/cache/args-npm-5.0.3-ec59f35e6d-ac39e65609.zip b/.yarn/cache/args-npm-5.0.3-ec59f35e6d-ac39e65609.zip new file mode 100644 index 000000000..afa01e95c Binary files /dev/null and b/.yarn/cache/args-npm-5.0.3-ec59f35e6d-ac39e65609.zip differ diff --git a/.yarn/cache/browser-stdout-npm-1.3.1-6b2376bf3f-b717b19b25.zip b/.yarn/cache/browser-stdout-npm-1.3.1-6b2376bf3f-b717b19b25.zip new file mode 100644 index 000000000..bf43caa58 Binary files /dev/null and b/.yarn/cache/browser-stdout-npm-1.3.1-6b2376bf3f-b717b19b25.zip differ diff --git a/.yarn/cache/call-me-maybe-npm-1.0.1-d07e74bc9c-d19e9d6ac2.zip b/.yarn/cache/call-me-maybe-npm-1.0.1-d07e74bc9c-d19e9d6ac2.zip new file mode 100644 index 000000000..c78283546 Binary files /dev/null and b/.yarn/cache/call-me-maybe-npm-1.0.1-d07e74bc9c-d19e9d6ac2.zip differ diff --git a/.yarn/cache/camelcase-npm-5.0.0-c808398846-8bfe920e04.zip b/.yarn/cache/camelcase-npm-5.0.0-c808398846-8bfe920e04.zip new file mode 100644 index 000000000..cdc64a3c0 Binary files /dev/null and b/.yarn/cache/camelcase-npm-5.0.0-c808398846-8bfe920e04.zip differ diff --git a/.yarn/cache/chai-as-promised-npm-7.1.1-cdc17e4612-7262868a5b.zip b/.yarn/cache/chai-as-promised-npm-7.1.1-cdc17e4612-7262868a5b.zip new file mode 100644 index 000000000..947fe7c3c Binary files /dev/null and b/.yarn/cache/chai-as-promised-npm-7.1.1-cdc17e4612-7262868a5b.zip differ diff --git a/.yarn/cache/chai-subset-npm-1.6.0-3cee47a65d-c85a64b42d.zip b/.yarn/cache/chai-subset-npm-1.6.0-3cee47a65d-c85a64b42d.zip new file mode 100644 index 000000000..1b60f97cf Binary files /dev/null and b/.yarn/cache/chai-subset-npm-1.6.0-3cee47a65d-c85a64b42d.zip differ diff --git a/.yarn/cache/crypto-js-npm-4.1.1-38a3b8c19d-b3747c12ee.zip b/.yarn/cache/crypto-js-npm-4.1.1-38a3b8c19d-b3747c12ee.zip new file mode 100644 index 000000000..e392e0d53 Binary files /dev/null and b/.yarn/cache/crypto-js-npm-4.1.1-38a3b8c19d-b3747c12ee.zip differ diff --git a/.yarn/cache/debug-npm-4.3.3-710fd4cc7f-14472d56fe.zip b/.yarn/cache/debug-npm-4.3.3-710fd4cc7f-14472d56fe.zip new file mode 100644 index 000000000..f2809aaad Binary files /dev/null and b/.yarn/cache/debug-npm-4.3.3-710fd4cc7f-14472d56fe.zip differ diff --git a/.yarn/cache/devtools-protocol-npm-0.0.981744-4636a91b0c-609901bff5.zip b/.yarn/cache/devtools-protocol-npm-0.0.981744-4636a91b0c-609901bff5.zip new file mode 100644 index 000000000..693a9a2e1 Binary files /dev/null and b/.yarn/cache/devtools-protocol-npm-0.0.981744-4636a91b0c-609901bff5.zip differ diff --git a/.yarn/cache/diff-npm-5.0.0-ad6900db18-f19fe29284.zip b/.yarn/cache/diff-npm-5.0.0-ad6900db18-f19fe29284.zip new file mode 100644 index 000000000..301b14287 Binary files /dev/null and b/.yarn/cache/diff-npm-5.0.0-ad6900db18-f19fe29284.zip differ diff --git a/.yarn/cache/docdash-npm-1.2.0-1aba121ac3-0c1ca8a2d8.zip b/.yarn/cache/docdash-npm-1.2.0-1aba121ac3-0c1ca8a2d8.zip new file mode 100644 index 000000000..937bcda8b Binary files /dev/null and b/.yarn/cache/docdash-npm-1.2.0-1aba121ac3-0c1ca8a2d8.zip differ diff --git a/.yarn/cache/dom-storage-npm-2.1.0-637aad78a8-b17f9f9a13.zip b/.yarn/cache/dom-storage-npm-2.1.0-637aad78a8-b17f9f9a13.zip new file mode 100644 index 000000000..2ebf40f47 Binary files /dev/null and b/.yarn/cache/dom-storage-npm-2.1.0-637aad78a8-b17f9f9a13.zip differ diff --git a/.yarn/cache/exports-loader-npm-3.1.0-083dec0b38-1bf210433b.zip b/.yarn/cache/exports-loader-npm-3.1.0-083dec0b38-1bf210433b.zip new file mode 100644 index 000000000..a92076625 Binary files /dev/null and b/.yarn/cache/exports-loader-npm-3.1.0-083dec0b38-1bf210433b.zip differ diff --git a/.yarn/cache/extract-zip-npm-2.0.1-92a28e392b-8cbda9debd.zip b/.yarn/cache/extract-zip-npm-2.0.1-92a28e392b-8cbda9debd.zip new file mode 100644 index 000000000..2169ae491 Binary files /dev/null and b/.yarn/cache/extract-zip-npm-2.0.1-92a28e392b-8cbda9debd.zip differ diff --git a/.yarn/cache/fast-glob-npm-2.2.7-f211fb26f4-304ccff1d4.zip b/.yarn/cache/fast-glob-npm-2.2.7-f211fb26f4-304ccff1d4.zip new file mode 100644 index 000000000..0aa8cf2b1 Binary files /dev/null and b/.yarn/cache/fast-glob-npm-2.2.7-f211fb26f4-304ccff1d4.zip differ diff --git a/.yarn/cache/glob-npm-7.2.0-bb4644d239-78a8ea9423.zip b/.yarn/cache/glob-npm-7.2.0-bb4644d239-78a8ea9423.zip new file mode 100644 index 000000000..0ef1638a6 Binary files /dev/null and b/.yarn/cache/glob-npm-7.2.0-bb4644d239-78a8ea9423.zip differ diff --git a/.yarn/cache/glob-to-regexp-npm-0.3.0-4f55888857-d34b3219d8.zip b/.yarn/cache/glob-to-regexp-npm-0.3.0-4f55888857-d34b3219d8.zip new file mode 100644 index 000000000..399bb738f Binary files /dev/null and b/.yarn/cache/glob-to-regexp-npm-0.3.0-4f55888857-d34b3219d8.zip differ diff --git a/.yarn/cache/globby-npm-9.2.0-686548dc5f-9b4cb70aa0.zip b/.yarn/cache/globby-npm-9.2.0-686548dc5f-9b4cb70aa0.zip new file mode 100644 index 000000000..de3770ca0 Binary files /dev/null and b/.yarn/cache/globby-npm-9.2.0-686548dc5f-9b4cb70aa0.zip differ diff --git a/.yarn/cache/growl-npm-1.10.5-2d1da54198-4b86685de6.zip b/.yarn/cache/growl-npm-1.10.5-2d1da54198-4b86685de6.zip new file mode 100644 index 000000000..b05a103da Binary files /dev/null and b/.yarn/cache/growl-npm-1.10.5-2d1da54198-4b86685de6.zip differ diff --git a/.yarn/cache/jest-environment-jsdom-npm-28.1.2-225ffc028f-73388b5cde.zip b/.yarn/cache/jest-environment-jsdom-npm-28.1.2-225ffc028f-73388b5cde.zip new file mode 100644 index 000000000..9e69fc770 Binary files /dev/null and b/.yarn/cache/jest-environment-jsdom-npm-28.1.2-225ffc028f-73388b5cde.zip differ diff --git a/.yarn/cache/jsdom-npm-19.0.0-f0768fdc93-94b693bf4a.zip b/.yarn/cache/jsdom-npm-19.0.0-f0768fdc93-94b693bf4a.zip new file mode 100644 index 000000000..d3f6806c2 Binary files /dev/null and b/.yarn/cache/jsdom-npm-19.0.0-f0768fdc93-94b693bf4a.zip differ diff --git a/.yarn/cache/just-extend-npm-4.2.1-ccc4201277-ff9fdede24.zip b/.yarn/cache/just-extend-npm-4.2.1-ccc4201277-ff9fdede24.zip new file mode 100644 index 000000000..8e6593fad Binary files /dev/null and b/.yarn/cache/just-extend-npm-4.2.1-ccc4201277-ff9fdede24.zip differ diff --git a/.yarn/cache/leven-npm-2.1.0-19f0a16606-f7b4a01b15.zip b/.yarn/cache/leven-npm-2.1.0-19f0a16606-f7b4a01b15.zip new file mode 100644 index 000000000..6eba0706b Binary files /dev/null and b/.yarn/cache/leven-npm-2.1.0-19f0a16606-f7b4a01b15.zip differ diff --git a/.yarn/cache/lodash.get-npm-4.4.2-7bda64ed87-e403047ddb.zip b/.yarn/cache/lodash.get-npm-4.4.2-7bda64ed87-e403047ddb.zip new file mode 100644 index 000000000..63cd7ccfc Binary files /dev/null and b/.yarn/cache/lodash.get-npm-4.4.2-7bda64ed87-e403047ddb.zip differ diff --git a/.yarn/cache/minimatch-npm-4.2.1-558ec7f418-2b1514e3d0.zip b/.yarn/cache/minimatch-npm-4.2.1-558ec7f418-2b1514e3d0.zip new file mode 100644 index 000000000..e3bca2199 Binary files /dev/null and b/.yarn/cache/minimatch-npm-4.2.1-558ec7f418-2b1514e3d0.zip differ diff --git a/.yarn/cache/mocha-headless-chrome-npm-4.0.0-b66897187f-ab802a342d.zip b/.yarn/cache/mocha-headless-chrome-npm-4.0.0-b66897187f-ab802a342d.zip new file mode 100644 index 000000000..d5390cbef Binary files /dev/null and b/.yarn/cache/mocha-headless-chrome-npm-4.0.0-b66897187f-ab802a342d.zip differ diff --git a/.yarn/cache/mocha-npm-9.2.2-f7735febb8-4d5ca4ce33.zip b/.yarn/cache/mocha-npm-9.2.2-f7735febb8-4d5ca4ce33.zip new file mode 100644 index 000000000..11e8b0c72 Binary files /dev/null and b/.yarn/cache/mocha-npm-9.2.2-f7735febb8-4d5ca4ce33.zip differ diff --git a/.yarn/cache/mri-npm-1.1.4-d22a399f26-e65b9aed3b.zip b/.yarn/cache/mri-npm-1.1.4-d22a399f26-e65b9aed3b.zip new file mode 100644 index 000000000..5eb6997d6 Binary files /dev/null and b/.yarn/cache/mri-npm-1.1.4-d22a399f26-e65b9aed3b.zip differ diff --git a/.yarn/cache/nanoid-npm-3.3.1-bdd760bee0-4ef0969e1b.zip b/.yarn/cache/nanoid-npm-3.3.1-bdd760bee0-4ef0969e1b.zip new file mode 100644 index 000000000..6953ccbb0 Binary files /dev/null and b/.yarn/cache/nanoid-npm-3.3.1-bdd760bee0-4ef0969e1b.zip differ diff --git a/.yarn/cache/nise-npm-5.1.1-210b3fdf40-d8be29e84a.zip b/.yarn/cache/nise-npm-5.1.1-210b3fdf40-d8be29e84a.zip new file mode 100644 index 000000000..98d03b105 Binary files /dev/null and b/.yarn/cache/nise-npm-5.1.1-210b3fdf40-d8be29e84a.zip differ diff --git a/.yarn/cache/nock-npm-13.2.8-6b3bcf0f50-656f696d3c.zip b/.yarn/cache/nock-npm-13.2.8-6b3bcf0f50-656f696d3c.zip new file mode 100644 index 000000000..274e4ceed Binary files /dev/null and b/.yarn/cache/nock-npm-13.2.8-6b3bcf0f50-656f696d3c.zip differ diff --git a/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip b/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip new file mode 100644 index 000000000..2a4a26dfd Binary files /dev/null and b/.yarn/cache/propagate-npm-2.0.1-2074bf76d3-c4febaee2b.zip differ diff --git a/.yarn/cache/proxy-from-env-npm-1.1.0-c13d07f26b-ed7fcc2ba0.zip b/.yarn/cache/proxy-from-env-npm-1.1.0-c13d07f26b-ed7fcc2ba0.zip new file mode 100644 index 000000000..a58e6bf3e Binary files /dev/null and b/.yarn/cache/proxy-from-env-npm-1.1.0-c13d07f26b-ed7fcc2ba0.zip differ diff --git a/.yarn/cache/puppeteer-npm-13.7.0-e6812428d2-4062b3ac33.zip b/.yarn/cache/puppeteer-npm-13.7.0-e6812428d2-4062b3ac33.zip new file mode 100644 index 000000000..2b756cde0 Binary files /dev/null and b/.yarn/cache/puppeteer-npm-13.7.0-e6812428d2-4062b3ac33.zip differ diff --git a/.yarn/cache/raw-loader-npm-0.5.1-842d4ead25-8051ec0b80.zip b/.yarn/cache/raw-loader-npm-0.5.1-842d4ead25-8051ec0b80.zip new file mode 100644 index 000000000..0089f24de Binary files /dev/null and b/.yarn/cache/raw-loader-npm-0.5.1-842d4ead25-8051ec0b80.zip differ diff --git a/.yarn/cache/script-loader-npm-0.7.2-cfe1d20d30-e01b3fb3e5.zip b/.yarn/cache/script-loader-npm-0.7.2-cfe1d20d30-e01b3fb3e5.zip new file mode 100644 index 000000000..914621bdc Binary files /dev/null and b/.yarn/cache/script-loader-npm-0.7.2-cfe1d20d30-e01b3fb3e5.zip differ diff --git a/.yarn/cache/sinon-npm-13.0.2-8544b59862-237f21c8c4.zip b/.yarn/cache/sinon-npm-13.0.2-8544b59862-237f21c8c4.zip new file mode 100644 index 000000000..e68a48c36 Binary files /dev/null and b/.yarn/cache/sinon-npm-13.0.2-8544b59862-237f21c8c4.zip differ diff --git a/.yarn/cache/tscpaths-npm-0.0.9-90fb6cfbb0-384fc9f22d.zip b/.yarn/cache/tscpaths-npm-0.0.9-90fb6cfbb0-384fc9f22d.zip new file mode 100644 index 000000000..ebd9c888c Binary files /dev/null and b/.yarn/cache/tscpaths-npm-0.0.9-90fb6cfbb0-384fc9f22d.zip differ diff --git a/.yarn/cache/unbzip2-stream-npm-1.4.3-c5582d6a9f-0e67c4a91f.zip b/.yarn/cache/unbzip2-stream-npm-1.4.3-c5582d6a9f-0e67c4a91f.zip new file mode 100644 index 000000000..88b66833a Binary files /dev/null and b/.yarn/cache/unbzip2-stream-npm-1.4.3-c5582d6a9f-0e67c4a91f.zip differ diff --git a/.yarn/cache/whatwg-url-npm-10.0.0-769b9530cc-a21ec309c5.zip b/.yarn/cache/whatwg-url-npm-10.0.0-769b9530cc-a21ec309c5.zip new file mode 100644 index 000000000..2b1305938 Binary files /dev/null and b/.yarn/cache/whatwg-url-npm-10.0.0-769b9530cc-a21ec309c5.zip differ diff --git a/.yarn/cache/workerpool-npm-6.2.0-d2a722f6bb-3493b4f0ef.zip b/.yarn/cache/workerpool-npm-6.2.0-d2a722f6bb-3493b4f0ef.zip new file mode 100644 index 000000000..74dcf5620 Binary files /dev/null and b/.yarn/cache/workerpool-npm-6.2.0-d2a722f6bb-3493b4f0ef.zip differ diff --git a/.yarn/cache/ws-npm-8.5.0-8e99728c84-76f2f90e40.zip b/.yarn/cache/ws-npm-8.5.0-8e99728c84-76f2f90e40.zip new file mode 100644 index 000000000..045d2bf79 Binary files /dev/null and b/.yarn/cache/ws-npm-8.5.0-8e99728c84-76f2f90e40.zip differ diff --git a/.yarn/cache/yargs-parser-npm-20.2.4-1de20916a6-d251998a37.zip b/.yarn/cache/yargs-parser-npm-20.2.4-1de20916a6-d251998a37.zip new file mode 100644 index 000000000..fe57a9c8d Binary files /dev/null and b/.yarn/cache/yargs-parser-npm-20.2.4-1de20916a6-d251998a37.zip differ diff --git a/package.json b/package.json index cf4efed81..cd95de36a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "build:desktop": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/desktop --exclude @standardnotes/components-meta run build", "build:mobile": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/mobile --exclude @standardnotes/components-meta run build", "build:web-server": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/web-server --exclude @standardnotes/components-meta run build", + "build:snjs": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/snjs --exclude @standardnotes/components-meta run build", "start:server:web": "lerna run start --scope=@standardnotes/web-server", "start:server:web:localhost": "lerna run start:no-binding --scope=@standardnotes/web-server", "prepare": "husky install", diff --git a/packages/filepicker/example/package.json b/packages/filepicker/example/package.json index 561517c75..89b9ffd79 100644 --- a/packages/filepicker/example/package.json +++ b/packages/filepicker/example/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@standardnotes/sncrypto-web": "workspace:*", - "@standardnotes/snjs": "^2.61.3", + "@standardnotes/snjs": "workspace:*", "regenerator-runtime": "^0.13.9" } } diff --git a/packages/mobile/metro.config.js b/packages/mobile/metro.config.js index cae5cf54d..4f8f48d42 100644 --- a/packages/mobile/metro.config.js +++ b/packages/mobile/metro.config.js @@ -30,7 +30,8 @@ module.exports = (async () => { '../services', '../files', '../utils', - '../sncrypto-common' + '../sncrypto-common', + '../snjs', ], transformer: { getTransformOptions: async () => ({ diff --git a/packages/mobile/package.json b/packages/mobile/package.json index c8ab32818..3764cda7a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -45,7 +45,7 @@ "@standardnotes/react-native-textview": "1.1.0", "@standardnotes/react-native-utils": "1.0.1", "@standardnotes/sncrypto-common": "workspace:*", - "@standardnotes/snjs": "^2.118.3", + "@standardnotes/snjs": "workspace:*", "@standardnotes/stylekit": "5.29.3", "@standardnotes/utils": "workspace:*", "@standardnotes/web": "workspace:*", diff --git a/packages/snjs/.browserslistrc b/packages/snjs/.browserslistrc new file mode 100644 index 000000000..4eb9144fc --- /dev/null +++ b/packages/snjs/.browserslistrc @@ -0,0 +1,7 @@ +Edge 16 +Firefox 53 +Chrome 57 +Safari 11 +Opera 44 +ios 11 +ChromeAndroid 84 diff --git a/packages/snjs/.eslintignore b/packages/snjs/.eslintignore new file mode 100644 index 000000000..5d09b15e4 --- /dev/null +++ b/packages/snjs/.eslintignore @@ -0,0 +1,5 @@ +node_modules +dist +test +*.config.js +mocha/**/* \ No newline at end of file diff --git a/packages/snjs/.eslintrc b/packages/snjs/.eslintrc new file mode 100644 index 000000000..42e723b15 --- /dev/null +++ b/packages/snjs/.eslintrc @@ -0,0 +1,9 @@ +{ + "extends": "../../.eslintrc", + "parserOptions": { + "project": "./linter.tsconfig.json" + }, + "rules": { + "@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": true }] + } +} diff --git a/packages/snjs/CHANGELOG.md b/packages/snjs/CHANGELOG.md new file mode 100644 index 000000000..6d18c8176 --- /dev/null +++ b/packages/snjs/CHANGELOG.md @@ -0,0 +1,2181 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [2.121.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.120.0...@standardnotes/snjs@2.121.0) (2022-07-06) + +### Bug Fixes + +* application interface import ([3727df8](https://github.com/standardnotes/snjs/commit/3727df8185dbc60dced14b28fc722da8b42c7c08)) +* update files package to latest version ([7097de1](https://github.com/standardnotes/snjs/commit/7097de1e470533de54cc8119052c99eee73f9d4b)) + +### Features + +* remove filepicker, features, services and models packages in favor of standardnotes/app repository ([27f474e](https://github.com/standardnotes/snjs/commit/27f474e859701c5713c8b6ed27cd1a4d5e4392bb)) + +# [2.120.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.119.0...@standardnotes/snjs@2.120.0) (2022-07-05) + +### Features + +* remove features package in favor of standardnotes/app repository ([bb8226b](https://github.com/standardnotes/snjs/commit/bb8226b77550707c2a981778a78fe3dccf1aaa03)) + +# [2.119.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.118.4...@standardnotes/snjs@2.119.0) (2022-07-05) + +### Features + +* remove encryption package in favor of standardnotes/app repository ([f6d1c9e](https://github.com/standardnotes/snjs/commit/f6d1c9ee538bb59ee7ac28c0d49ca682d4eb4d38)) + +## [2.118.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.118.3...@standardnotes/snjs@2.118.4) (2022-07-04) + +### Bug Fixes + +* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b)) +* location of testing tools in e2e test suite ([31f0088](https://github.com/standardnotes/snjs/commit/31f0088f4ad86ca3a0db45cc9a8a0b0fc6499d71)) +* move connect package to root of the repository ([717e0c4](https://github.com/standardnotes/snjs/commit/717e0c4916dc79388e16e564144416b9ab68b3ab)) +* move serve-static package to root of the repository ([a9bf9cb](https://github.com/standardnotes/snjs/commit/a9bf9cbe7cf9edad00b8f43b7dcc177c8fd0f529)) +* unit tests running ([9ddc55c](https://github.com/standardnotes/snjs/commit/9ddc55c59c781e2bcc366304a6d0cc88d0e0865d)) + +## [2.118.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.118.2...@standardnotes/snjs@2.118.3) (2022-06-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.118.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.118.1...@standardnotes/snjs@2.118.2) (2022-06-28) + +### Bug Fixes + +* dealloc handler in item controller ([ed42621](https://github.com/standardnotes/snjs/commit/ed42621844fb17fb06515fba54024ca3ba5f9fd4)) + +## [2.118.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.118.0...@standardnotes/snjs@2.118.1) (2022-06-28) + +### Bug Fixes + +* setting custom host ([#773](https://github.com/standardnotes/snjs/issues/773)) ([2fe27b0](https://github.com/standardnotes/snjs/commit/2fe27b0324486fad915a91096142579e649995b8)) + +# [2.118.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.11...@standardnotes/snjs@2.118.0) (2022-06-27) + +### Features + +* deprecate non-primary editors ([#772](https://github.com/standardnotes/snjs/issues/772)) ([60c8f2a](https://github.com/standardnotes/snjs/commit/60c8f2a6a057ac1a097b077143ed85a7607eba20)) + +## [2.117.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.10...@standardnotes/snjs@2.117.11) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.9...@standardnotes/snjs@2.117.10) (2022-06-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.8...@standardnotes/snjs@2.117.9) (2022-06-24) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.7...@standardnotes/snjs@2.117.8) (2022-06-23) + +### Bug Fixes + +* set host on user server ([#771](https://github.com/standardnotes/snjs/issues/771)) ([62d2c60](https://github.com/standardnotes/snjs/commit/62d2c60834b20b386b8c60f0ee172aec3e57ec05)) + +## [2.117.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.6...@standardnotes/snjs@2.117.7) (2022-06-22) + +### Bug Fixes + +* mobile keychain types ([#769](https://github.com/standardnotes/snjs/issues/769)) ([1fa6fb5](https://github.com/standardnotes/snjs/commit/1fa6fb57e398e60c27041b826540b6a1a6de5e91)) + +## [2.117.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.5...@standardnotes/snjs@2.117.6) (2022-06-22) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.4...@standardnotes/snjs@2.117.5) (2022-06-20) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.3...@standardnotes/snjs@2.117.4) (2022-06-16) + +### Bug Fixes + +* version comparison including beta/alpha using semver package ([#768](https://github.com/standardnotes/snjs/issues/768)) ([0cf2650](https://github.com/standardnotes/snjs/commit/0cf2650c0d54bb31c6c52f0d9834ddd6f259dba7)) + +## [2.117.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.2...@standardnotes/snjs@2.117.3) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.1...@standardnotes/snjs@2.117.2) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.117.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.117.0...@standardnotes/snjs@2.117.1) (2022-06-16) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.117.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.116.2...@standardnotes/snjs@2.117.0) (2022-06-15) + +### Features + +* new hosted components assets path ([32f477d](https://github.com/standardnotes/snjs/commit/32f477dbf403ec35374f53b27a7d661088f12568)) + +## [2.116.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.116.1...@standardnotes/snjs@2.116.2) (2022-06-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.116.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.116.0...@standardnotes/snjs@2.116.1) (2022-06-15) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.116.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.12...@standardnotes/snjs@2.116.0) (2022-06-13) + +### Features + +* ItemGroupController ([#765](https://github.com/standardnotes/snjs/issues/765)) ([13e8b90](https://github.com/standardnotes/snjs/commit/13e8b90344975c5ca3d097808c5c787debd43cf4)) + +## [2.115.12](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.11...@standardnotes/snjs@2.115.12) (2022-06-13) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.10...@standardnotes/snjs@2.115.11) (2022-06-11) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.9...@standardnotes/snjs@2.115.10) (2022-06-10) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.8...@standardnotes/snjs@2.115.9) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.6...@standardnotes/snjs@2.115.8) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.6...@standardnotes/snjs@2.115.7) (2022-06-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.5...@standardnotes/snjs@2.115.6) (2022-06-06) + +### Bug Fixes + +* freeze observers before looping ([#758](https://github.com/standardnotes/snjs/issues/758)) ([6da827a](https://github.com/standardnotes/snjs/commit/6da827a39cdba1406c51e2663c59cf34ae270cdb)) + +## [2.115.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.4...@standardnotes/snjs@2.115.5) (2022-06-06) + +### Bug Fixes + +* reverse title sort ([#757](https://github.com/standardnotes/snjs/issues/757)) ([dacee77](https://github.com/standardnotes/snjs/commit/dacee77488593ec71c670c1bfa62cc7f526c8b56)) + +## [2.115.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.3...@standardnotes/snjs@2.115.4) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.2...@standardnotes/snjs@2.115.3) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.1...@standardnotes/snjs@2.115.2) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.115.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.115.0...@standardnotes/snjs@2.115.1) (2022-06-03) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.115.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.12...@standardnotes/snjs@2.115.0) (2022-06-03) + +### Features + +* api service refactor -extract registration ([#733](https://github.com/standardnotes/snjs/issues/733)) ([1d7fac8](https://github.com/standardnotes/snjs/commit/1d7fac8c9dbb0fdb78a88743965a33c6d6a7d7d3)) + +## [2.114.12](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.11...@standardnotes/snjs@2.114.12) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.10...@standardnotes/snjs@2.114.11) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.9...@standardnotes/snjs@2.114.10) (2022-06-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.8...@standardnotes/snjs@2.114.9) (2022-06-01) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.7...@standardnotes/snjs@2.114.8) (2022-06-01) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.6...@standardnotes/snjs@2.114.7) (2022-05-31) + +### Bug Fixes + +* allow non json headers in external ext fetch ([fa792ce](https://github.com/standardnotes/snjs/commit/fa792ce19bee608fe89b0bcdbf931977c59a09de)) + +## [2.114.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.5...@standardnotes/snjs@2.114.6) (2022-05-30) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.4...@standardnotes/snjs@2.114.5) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.3...@standardnotes/snjs@2.114.4) (2022-05-27) + +### Bug Fixes + +* add dealloced flag to note controller ([22955cc](https://github.com/standardnotes/snjs/commit/22955cc49d4262ff7de6e192c78cbc24b7ea3a32)) + +## [2.114.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.2...@standardnotes/snjs@2.114.3) (2022-05-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.1...@standardnotes/snjs@2.114.2) (2022-05-26) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.114.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.114.0...@standardnotes/snjs@2.114.1) (2022-05-25) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.114.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.113.4...@standardnotes/snjs@2.114.0) (2022-05-24) + +### Features + +* un/protect multiple files ([#749](https://github.com/standardnotes/snjs/issues/749)) ([025cff4](https://github.com/standardnotes/snjs/commit/025cff4e9467f4cfe35efa0074019def192fb946)) + +## [2.113.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.113.3...@standardnotes/snjs@2.113.4) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.113.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.113.2...@standardnotes/snjs@2.113.3) (2022-05-24) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.113.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.113.1...@standardnotes/snjs@2.113.2) (2022-05-23) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.113.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.113.0...@standardnotes/snjs@2.113.1) (2022-05-23) + +### Bug Fixes + +* always push registration push ([#746](https://github.com/standardnotes/snjs/issues/746)) ([016df32](https://github.com/standardnotes/snjs/commit/016df32c16a1a8a035acf4ad48c59de3528932e7)) + +# [2.113.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.112.0...@standardnotes/snjs@2.113.0) (2022-05-22) + +### Features + +* optional files navigation ([#745](https://github.com/standardnotes/snjs/issues/745)) ([8512166](https://github.com/standardnotes/snjs/commit/851216615478b57b11a570173f94ee598bec31c0)) + +# [2.112.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.111.0...@standardnotes/snjs@2.112.0) (2022-05-21) + +### Features + +* generic protected access authorization ([#744](https://github.com/standardnotes/snjs/issues/744)) ([35095d2](https://github.com/standardnotes/snjs/commit/35095d27c54b813dc8ebc2c4a34e401cb237c6d0)) + +# [2.111.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.110.3...@standardnotes/snjs@2.111.0) (2022-05-21) + +### Features + +* display controllers ([#743](https://github.com/standardnotes/snjs/issues/743)) ([5fadce3](https://github.com/standardnotes/snjs/commit/5fadce37f1b3f2f51b8a90c257bc666ac7710074)) + +## [2.110.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.110.2...@standardnotes/snjs@2.110.3) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.110.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.110.1...@standardnotes/snjs@2.110.2) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.110.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.110.0...@standardnotes/snjs@2.110.1) (2022-05-20) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.110.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.6...@standardnotes/snjs@2.110.0) (2022-05-20) + +### Features + +* authentication with PKCE mechanism ([#719](https://github.com/standardnotes/snjs/issues/719)) ([1bc19b7](https://github.com/standardnotes/snjs/commit/1bc19b79decf83a563d1cf095ee2e56f738152d1)) + +## [2.109.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.5...@standardnotes/snjs@2.109.6) (2022-05-19) + +### Bug Fixes + +* ignore component viewer callbacks if dealloced ([d8eba55](https://github.com/standardnotes/snjs/commit/d8eba55aa2d8ba51f9cf89b1f2f1e3afb44c7822)) + +## [2.109.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.4...@standardnotes/snjs@2.109.5) (2022-05-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.109.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.3...@standardnotes/snjs@2.109.4) (2022-05-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.109.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.2...@standardnotes/snjs@2.109.3) (2022-05-17) + +### Bug Fixes + +* workspace signout ([a79cbfc](https://github.com/standardnotes/snjs/commit/a79cbfc0baff5fba36b2bb1479ea558b43d445b1)) + +## [2.109.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.1...@standardnotes/snjs@2.109.2) (2022-05-17) + +### Bug Fixes + +* workspace signout all ([0ac4501](https://github.com/standardnotes/snjs/commit/0ac45019428946016ef02384b07b8190378008fc)) + +## [2.109.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.109.0...@standardnotes/snjs@2.109.1) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.109.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.108.4...@standardnotes/snjs@2.109.0) (2022-05-17) + +### Features + +* remove basic user role and core subscription plan ([#741](https://github.com/standardnotes/snjs/issues/741)) ([7800ecd](https://github.com/standardnotes/snjs/commit/7800ecd119e7bbb5872d48bd7806b5d0f5522c0e)) + +## [2.108.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.108.3...@standardnotes/snjs@2.108.4) (2022-05-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.108.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.108.2...@standardnotes/snjs@2.108.3) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.108.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.108.1...@standardnotes/snjs@2.108.2) (2022-05-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.108.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.108.0...@standardnotes/snjs@2.108.1) (2022-05-16) + +### Bug Fixes + +* role name in FeaturesService unit tests ([#736](https://github.com/standardnotes/snjs/issues/736)) ([964adee](https://github.com/standardnotes/snjs/commit/964adee52d89ef58fdc7efe684349834d34f8c44)) +* unit tests for role and subscription removal ([bd9b6c2](https://github.com/standardnotes/snjs/commit/bd9b6c221786ad5533b5d03799cef2fc14af707a)) + +# [2.108.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.107.3...@standardnotes/snjs@2.108.0) (2022-05-16) + +### Features + +* remove basic user role and core subscription plan names ([304e232](https://github.com/standardnotes/snjs/commit/304e232e738456a93374de869117b5579e8a8f57)) + +## [2.107.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.107.2...@standardnotes/snjs@2.107.3) (2022-05-13) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.107.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.107.1...@standardnotes/snjs@2.107.2) (2022-05-13) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.107.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.107.0...@standardnotes/snjs@2.107.1) (2022-05-12) + +### Bug Fixes + +* do not send custom headers for external request ([#732](https://github.com/standardnotes/snjs/issues/732)) ([372a306](https://github.com/standardnotes/snjs/commit/372a306938e16f2463f4f0434bf287af7809d264)) + +# [2.107.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.12...@standardnotes/snjs@2.107.0) (2022-05-12) + +### Features + +* file desktop backups ([#731](https://github.com/standardnotes/snjs/issues/731)) ([0dbce7d](https://github.com/standardnotes/snjs/commit/0dbce7dc9712fde848445b951079c81479c8bc11)) + +## [2.106.12](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.11...@standardnotes/snjs@2.106.12) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.10...@standardnotes/snjs@2.106.11) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.9...@standardnotes/snjs@2.106.10) (2022-05-12) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.8...@standardnotes/snjs@2.106.9) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.7...@standardnotes/snjs@2.106.8) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.6...@standardnotes/snjs@2.106.7) (2022-05-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.5...@standardnotes/snjs@2.106.6) (2022-05-06) + +### Bug Fixes + +* update note count after remote delete ([#725](https://github.com/standardnotes/snjs/issues/725)) ([043edce](https://github.com/standardnotes/snjs/commit/043edcea9dfc7a8b234363910791f173880efdb9)) + +## [2.106.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.4...@standardnotes/snjs@2.106.5) (2022-05-06) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.3...@standardnotes/snjs@2.106.4) (2022-05-05) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.106.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.2...@standardnotes/snjs@2.106.3) (2022-05-05) + +### Bug Fixes + +* apply @standardnotes/config eslint and prettier rules to SNJS ([5ff4e38](https://github.com/standardnotes/snjs/commit/5ff4e388e813ba4bf5d9c5ae5186a47a85b67695)) + +## [2.106.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.0...@standardnotes/snjs@2.106.2) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +## [2.106.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.106.0...@standardnotes/snjs@2.106.1) (2022-05-04) + +### Bug Fixes + +* config package missing dependencies ([3dec12f](https://github.com/standardnotes/snjs/commit/3dec12fa4a83a8aed8419819eafb7c34795cb09f)) + +# [2.106.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.105.3...@standardnotes/snjs@2.106.0) (2022-05-03) + +### Features + +* editors => note types ([#722](https://github.com/standardnotes/snjs/issues/722)) ([898f27a](https://github.com/standardnotes/snjs/commit/898f27ab9928a46b33662c0454ec6489884ec276)) + +## [2.105.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.105.2...@standardnotes/snjs@2.105.3) (2022-05-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.105.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.105.1...@standardnotes/snjs@2.105.2) (2022-05-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.105.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.105.0...@standardnotes/snjs@2.105.1) (2022-05-02) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.105.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.104.2...@standardnotes/snjs@2.105.0) (2022-04-29) + +### Features + +* service diagnostics ([#718](https://github.com/standardnotes/snjs/issues/718)) ([17cf40f](https://github.com/standardnotes/snjs/commit/17cf40f4489c8f1915b19c0318d252cf83bc050d)) + +## [2.104.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.104.1...@standardnotes/snjs@2.104.2) (2022-04-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.104.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.104.0...@standardnotes/snjs@2.104.1) (2022-04-28) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.104.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.103.0...@standardnotes/snjs@2.104.0) (2022-04-28) + +### Features + +* refactor sncrypto to add unified sha256 and base64 usage ([#715](https://github.com/standardnotes/snjs/issues/715)) ([93aef4d](https://github.com/standardnotes/snjs/commit/93aef4d39228a63f01aa90a88e5d28c3375ed707)) + +# [2.103.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.102.0...@standardnotes/snjs@2.103.0) (2022-04-27) + +### Features + +* make files sortable using setDisplayOptions ([#713](https://github.com/standardnotes/snjs/issues/713)) ([b2088bf](https://github.com/standardnotes/snjs/commit/b2088bfa169ddea9aeddf9dfb20a098991aed875)) + +# [2.102.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.101.1...@standardnotes/snjs@2.102.0) (2022-04-27) + +### Features + +* add canaAttemptDecryptionOfItem method ([#712](https://github.com/standardnotes/snjs/issues/712)) ([f7b93ca](https://github.com/standardnotes/snjs/commit/f7b93ca1e7ac6dee684025ebf2b89a0ba04059dd)) + +## [2.101.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.101.0...@standardnotes/snjs@2.101.1) (2022-04-27) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.101.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.100.0...@standardnotes/snjs@2.101.0) (2022-04-27) + +### Features + +* file upload and download progress ([#711](https://github.com/standardnotes/snjs/issues/711)) ([79fceed](https://github.com/standardnotes/snjs/commit/79fceeda4066dc66142f18c9c7b110757ca67e69)) + +# [2.100.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.99.3...@standardnotes/snjs@2.100.0) (2022-04-26) + +### Features + +* sign out all workspaces option ([#710](https://github.com/standardnotes/snjs/issues/710)) ([5d44b9f](https://github.com/standardnotes/snjs/commit/5d44b9f5c4bb2d871db974ec8f06eddcf4419275)) + +## [2.99.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.99.2...@standardnotes/snjs@2.99.3) (2022-04-26) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.99.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.99.1...@standardnotes/snjs@2.99.2) (2022-04-25) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.99.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.99.0...@standardnotes/snjs@2.99.1) (2022-04-22) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.99.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.98.2...@standardnotes/snjs@2.99.0) (2022-04-22) + +### Features + +* add getSubscriptionSetting function ([#700](https://github.com/standardnotes/snjs/issues/700)) ([9ce373e](https://github.com/standardnotes/snjs/commit/9ce373e7651d2fcfd2709c48c3565673dc4b776a)) + +## [2.98.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.98.1...@standardnotes/snjs@2.98.2) (2022-04-22) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.98.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.98.0...@standardnotes/snjs@2.98.1) (2022-04-22) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.98.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.10...@standardnotes/snjs@2.98.0) (2022-04-22) + +### Features + +* in memory file cache ([#705](https://github.com/standardnotes/snjs/issues/705)) ([fca294a](https://github.com/standardnotes/snjs/commit/fca294a84256e03272e3b1b29b3dc478cddf9c28)) + +## [2.97.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.9...@standardnotes/snjs@2.97.10) (2022-04-22) + +### Bug Fixes + +* update auth to allow expired token session sign outs ([#706](https://github.com/standardnotes/snjs/issues/706)) ([25f9bc3](https://github.com/standardnotes/snjs/commit/25f9bc3e8b0ced41f249bd57ed4c3fbfe66a0127)) + +## [2.97.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.8...@standardnotes/snjs@2.97.9) (2022-04-21) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.97.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.7...@standardnotes/snjs@2.97.8) (2022-04-21) + +### Bug Fixes + +* update services to latest versions ([#702](https://github.com/standardnotes/snjs/issues/702)) ([1077748](https://github.com/standardnotes/snjs/commit/1077748b26a6ed3277ab7f9b436c8c3a3a58793b)) + +## [2.97.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.6...@standardnotes/snjs@2.97.7) (2022-04-21) + +### Bug Fixes + +* abort key recovery after aborted challenge ([#703](https://github.com/standardnotes/snjs/issues/703)) ([a67fb7e](https://github.com/standardnotes/snjs/commit/a67fb7e8cde41a5c9fadf545933e35d525faeaf0)) + +## [2.97.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.5...@standardnotes/snjs@2.97.6) (2022-04-20) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.97.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.4...@standardnotes/snjs@2.97.5) (2022-04-20) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.97.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.3...@standardnotes/snjs@2.97.4) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.97.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.2...@standardnotes/snjs@2.97.3) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.97.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.1...@standardnotes/snjs@2.97.2) (2022-04-19) + +### Bug Fixes + +* properly handle encrypted item changes in collections ([#698](https://github.com/standardnotes/snjs/issues/698)) ([8b23c65](https://github.com/standardnotes/snjs/commit/8b23c6555decbdc5099fc4228ff889f7e5c8eb85)) + +## [2.97.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.97.0...@standardnotes/snjs@2.97.1) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.97.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.96.3...@standardnotes/snjs@2.97.0) (2022-04-19) + +### Features + +* require authentication for all backup types ([#696](https://github.com/standardnotes/snjs/issues/696)) ([bdb8d6a](https://github.com/standardnotes/snjs/commit/bdb8d6a6897607de528307cacd6a173ad0d32cb5)) + +## [2.96.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.96.2...@standardnotes/snjs@2.96.3) (2022-04-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.96.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.96.1...@standardnotes/snjs@2.96.2) (2022-04-18) + +### Bug Fixes + +* make timestamps required in payload construction ([#695](https://github.com/standardnotes/snjs/issues/695)) ([b3326c0](https://github.com/standardnotes/snjs/commit/b3326c0a998cd9d44a76afc377f182885ef48275)) + +## [2.96.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.96.0...@standardnotes/snjs@2.96.1) (2022-04-18) + +### Bug Fixes + +* don't download role based features if using offline repo ([0d375b8](https://github.com/standardnotes/snjs/commit/0d375b85eab36b489fc05c6797a70284fe91f09b)) + +# [2.96.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.95.0...@standardnotes/snjs@2.96.0) (2022-04-15) + +### Features + +* introduce sync resolved payloads to ensure deltas always return up to date dirty state ([#694](https://github.com/standardnotes/snjs/issues/694)) ([e5278ba](https://github.com/standardnotes/snjs/commit/e5278ba0b2afa987c37f009a2101fb91949d44c6)) + +# [2.95.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.13...@standardnotes/snjs@2.95.0) (2022-04-15) + +### Features + +* no merge payloads in payload manager ([#693](https://github.com/standardnotes/snjs/issues/693)) ([68a577c](https://github.com/standardnotes/snjs/commit/68a577cb887fd2d5556dc9ddec461f6ae665fcb6)) + +## [2.94.13](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.12...@standardnotes/snjs@2.94.13) (2022-04-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.12](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.11...@standardnotes/snjs@2.94.12) (2022-04-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.10...@standardnotes/snjs@2.94.11) (2022-04-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.9...@standardnotes/snjs@2.94.10) (2022-04-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.8...@standardnotes/snjs@2.94.9) (2022-04-14) + +### Bug Fixes + +* map ignored item timestamps so application remains in sync ([#692](https://github.com/standardnotes/snjs/issues/692)) ([966cbb0](https://github.com/standardnotes/snjs/commit/966cbb0c254d4d95c802bd8951488a499d1f8bef)) + +## [2.94.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.7...@standardnotes/snjs@2.94.8) (2022-04-14) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.6...@standardnotes/snjs@2.94.7) (2022-04-14) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.5...@standardnotes/snjs@2.94.6) (2022-04-13) + +### Bug Fixes + +* emit changed deleted items as removed ([#691](https://github.com/standardnotes/snjs/issues/691)) ([b12f257](https://github.com/standardnotes/snjs/commit/b12f257b02d46ad9c717e6c51d6e7ca7e9c06959)) + +## [2.94.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.4...@standardnotes/snjs@2.94.5) (2022-04-12) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.3...@standardnotes/snjs@2.94.4) (2022-04-12) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.2...@standardnotes/snjs@2.94.3) (2022-04-11) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.94.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.1...@standardnotes/snjs@2.94.2) (2022-04-11) + +### Bug Fixes + +* add parent tags setting when creating note in child tag ([#687](https://github.com/standardnotes/snjs/issues/687)) ([00d4eac](https://github.com/standardnotes/snjs/commit/00d4eac5486564e6b8a402ada10474b8792b24f1)) + +## [2.94.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.94.0...@standardnotes/snjs@2.94.1) (2022-04-01) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.94.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.93.4...@standardnotes/snjs@2.94.0) (2022-04-01) + +### Features + +* content interfaces and model type strictness ([#685](https://github.com/standardnotes/snjs/issues/685)) ([e2450c5](https://github.com/standardnotes/snjs/commit/e2450c59e8309d7080efaa03905b2abc728d9403)) + +## [2.93.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.93.3...@standardnotes/snjs@2.93.4) (2022-04-01) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.93.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.93.2...@standardnotes/snjs@2.93.3) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.93.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.93.1...@standardnotes/snjs@2.93.2) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.93.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.93.0...@standardnotes/snjs@2.93.1) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.93.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.10...@standardnotes/snjs@2.93.0) (2022-03-31) + +### Features + +* encryption and models packages ([#679](https://github.com/standardnotes/snjs/issues/679)) ([5e03d48](https://github.com/standardnotes/snjs/commit/5e03d48aba7e3dd266117201139ab869b1f70cc9)) + +## [2.92.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.9...@standardnotes/snjs@2.92.10) (2022-03-31) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.8...@standardnotes/snjs@2.92.9) (2022-03-30) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.7...@standardnotes/snjs@2.92.8) (2022-03-29) + +### Bug Fixes + +* spellcheck toggle ([#677](https://github.com/standardnotes/snjs/issues/677)) ([e6fd4c8](https://github.com/standardnotes/snjs/commit/e6fd4c8b339391b92996d926403b4484f961638a)) + +## [2.92.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.6...@standardnotes/snjs@2.92.7) (2022-03-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.5...@standardnotes/snjs@2.92.6) (2022-03-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.4...@standardnotes/snjs@2.92.5) (2022-03-28) + +### Bug Fixes + +* memoize needs keychain repair value to minimize keychain requests for mobile ([#676](https://github.com/standardnotes/snjs/issues/676)) ([177a967](https://github.com/standardnotes/snjs/commit/177a967fdd758399d1acbca970003a086e566167)) + +## [2.92.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.3...@standardnotes/snjs@2.92.4) (2022-03-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.2...@standardnotes/snjs@2.92.3) (2022-03-25) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.1...@standardnotes/snjs@2.92.2) (2022-03-25) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.92.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.92.0...@standardnotes/snjs@2.92.1) (2022-03-24) + +### Bug Fixes + +* export enums without declaring them with 'const' ([#673](https://github.com/standardnotes/snjs/issues/673)) ([5a075b2](https://github.com/standardnotes/snjs/commit/5a075b29f5fd87eccb02d0d2b7b759c20ab91779)) + +# [2.92.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.91.2...@standardnotes/snjs@2.92.0) (2022-03-24) + +### Features + +* handle sync readonly errors ([65c4f52](https://github.com/standardnotes/snjs/commit/65c4f521c31417601cb647a8f9031e914ad4bbc8)) + +## [2.91.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.91.1...@standardnotes/snjs@2.91.2) (2022-03-23) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.91.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.91.0...@standardnotes/snjs@2.91.1) (2022-03-23) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.91.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.90.0...@standardnotes/snjs@2.91.0) (2022-03-23) + +### Features + +* demo share tokens ([#670](https://github.com/standardnotes/snjs/issues/670)) ([4741836](https://github.com/standardnotes/snjs/commit/474183668433724960444de8f3acf293a66d5219)) + +# [2.90.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.89.4...@standardnotes/snjs@2.90.0) (2022-03-22) + +### Features + +* backup importing to allow various backup formats ([#668](https://github.com/standardnotes/snjs/issues/668)) ([5effc07](https://github.com/standardnotes/snjs/commit/5effc07d1b722da904c876ef4797020dbc0656b8)) + +## [2.89.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.89.3...@standardnotes/snjs@2.89.4) (2022-03-22) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.89.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.89.2...@standardnotes/snjs@2.89.3) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.89.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.89.1...@standardnotes/snjs@2.89.2) (2022-03-21) + +### Bug Fixes + +* pass mutation type instead of undefined ([#667](https://github.com/standardnotes/snjs/issues/667)) ([2288896](https://github.com/standardnotes/snjs/commit/2288896d38fd14ea98698b3d85850bac86e54321)) + +## [2.89.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.89.0...@standardnotes/snjs@2.89.1) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.89.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.88.3...@standardnotes/snjs@2.89.0) (2022-03-21) + +### Features + +* mutation service ([#665](https://github.com/standardnotes/snjs/issues/665)) ([89e4357](https://github.com/standardnotes/snjs/commit/89e4357a92270d79f4942f842f6d2adb9d7711d6)) +* use workspace terminology for switcher ([#666](https://github.com/standardnotes/snjs/issues/666)) ([e0740e7](https://github.com/standardnotes/snjs/commit/e0740e7811164e2fbd0bbc70e79d5bd3092bbe6a)) + +## [2.88.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.88.2...@standardnotes/snjs@2.88.3) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.88.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.88.1...@standardnotes/snjs@2.88.2) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.88.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.88.0...@standardnotes/snjs@2.88.1) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.88.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.87.1...@standardnotes/snjs@2.88.0) (2022-03-21) + +### Features + +* add file type ([#663](https://github.com/standardnotes/snjs/issues/663)) ([5fdf86f](https://github.com/standardnotes/snjs/commit/5fdf86ff9ced4766552a281bcc3a3e35e7a32a6d)) + +## [2.87.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.87.0...@standardnotes/snjs@2.87.1) (2022-03-21) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.87.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.86.4...@standardnotes/snjs@2.87.0) (2022-03-19) + +### Features + +* option to toggle adding note to all parent folders ([#659](https://github.com/standardnotes/snjs/issues/659)) ([7fb8ff7](https://github.com/standardnotes/snjs/commit/7fb8ff7c7671b730d760505189a85dde9bfefe14)) + +## [2.86.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.86.3...@standardnotes/snjs@2.86.4) (2022-03-18) + +### Bug Fixes + +* tag search causing regex exception ([#660](https://github.com/standardnotes/snjs/issues/660)) ([2c592f5](https://github.com/standardnotes/snjs/commit/2c592f5d58b61e1b7066e679127f2881efbda2d5)) + +## [2.86.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.86.2...@standardnotes/snjs@2.86.3) (2022-03-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.86.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.86.1...@standardnotes/snjs@2.86.2) (2022-03-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.86.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.86.0...@standardnotes/snjs@2.86.1) (2022-03-17) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.86.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.85.3...@standardnotes/snjs@2.86.0) (2022-03-16) + +### Features + +* restricted component permissions ([e90b2d6](https://github.com/standardnotes/snjs/commit/e90b2d6847920e4cd7a281145204ce2f346af88e)) + +## [2.85.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.85.2...@standardnotes/snjs@2.85.3) (2022-03-16) + +### Bug Fixes + +* service export ([a3e6441](https://github.com/standardnotes/snjs/commit/a3e6441f22e80e5ada03340271419de8040de495)) + +## [2.85.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.85.1...@standardnotes/snjs@2.85.2) (2022-03-16) + +### Bug Fixes + +* import location ([4a68cd2](https://github.com/standardnotes/snjs/commit/4a68cd2744a543476878c27c2b5c57432e6e2085)) + +## [2.85.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.85.0...@standardnotes/snjs@2.85.1) (2022-03-16) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.85.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.84.1...@standardnotes/snjs@2.85.0) (2022-03-16) + +### Features + +* delete file functionality ([#657](https://github.com/standardnotes/snjs/issues/657)) ([edec4f7](https://github.com/standardnotes/snjs/commit/edec4f7a65ef557ed5f47be4dddcf2b659ee28b4)) + +## [2.84.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.84.1...@standardnotes/snjs@2.84.2) (2022-03-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.84.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.84.0...@standardnotes/snjs@2.84.1) (2022-03-16) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.84.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.83.1...@standardnotes/snjs@2.84.0) (2022-03-15) + +### Features + +* delete account ([#655](https://github.com/standardnotes/snjs/issues/655)) ([00b41b5](https://github.com/standardnotes/snjs/commit/00b41b5c9de67d1ef6a0d7280e870c91cebbb4f2)) + +## [2.83.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.83.0...@standardnotes/snjs@2.83.1) (2022-03-15) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.83.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.82.0...@standardnotes/snjs@2.83.0) (2022-03-14) + +### Features + +* move vault into applications package ([#653](https://github.com/standardnotes/snjs/issues/653)) ([3d320eb](https://github.com/standardnotes/snjs/commit/3d320eb51ac74729ab8864f1c4c4f24d8fb794d5)) + +# [2.82.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.81.1...@standardnotes/snjs@2.82.0) (2022-03-13) + +### Features + +* add utility to get icon type for file mime type ([#652](https://github.com/standardnotes/snjs/issues/652)) ([2cf00de](https://github.com/standardnotes/snjs/commit/2cf00ded8594c7aa86015a340026f0a05613cc91)) + +## [2.81.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.81.0...@standardnotes/snjs@2.81.1) (2022-03-11) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.81.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.5...@standardnotes/snjs@2.81.0) (2022-03-11) + +### Features + +* remove ext property from files in favor of mimetype ([#650](https://github.com/standardnotes/snjs/issues/650)) ([d2e7e23](https://github.com/standardnotes/snjs/commit/d2e7e23ec117c505f2f38b9edea539ad3a6d70e2)) + +## [2.80.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.4...@standardnotes/snjs@2.80.5) (2022-03-11) + +### Bug Fixes + +* allow subscription state from any server ([326c05a](https://github.com/standardnotes/snjs/commit/326c05aa71fd4dfd5ca6789f2f8edff15121fd7d)) + +## [2.80.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.3...@standardnotes/snjs@2.80.4) (2022-03-11) + +### Bug Fixes + +* valet token request params ([8c9a396](https://github.com/standardnotes/snjs/commit/8c9a396fa5650815156941dff95e9ae736a8354d)) + +## [2.80.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.2...@standardnotes/snjs@2.80.3) (2022-03-11) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.80.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.1...@standardnotes/snjs@2.80.2) (2022-03-11) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.80.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.80.0...@standardnotes/snjs@2.80.1) (2022-03-10) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.80.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.5...@standardnotes/snjs@2.80.0) (2022-03-10) + +### Features + +* store file mimeType along with name & ext ([#648](https://github.com/standardnotes/snjs/issues/648)) ([05bf273](https://github.com/standardnotes/snjs/commit/05bf2737282f2d068e354c4d05fbe5390a19e613)) + +## [2.79.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.4...@standardnotes/snjs@2.79.5) (2022-03-10) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.79.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.3...@standardnotes/snjs@2.79.4) (2022-03-10) + +### Bug Fixes + +* **temp:** remove markdown visual ([4ba193b](https://github.com/standardnotes/snjs/commit/4ba193b6848c15a94bbdb0e61edb30fea6c02c18)) + +## [2.79.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.2...@standardnotes/snjs@2.79.3) (2022-03-10) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.79.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.1...@standardnotes/snjs@2.79.2) (2022-03-10) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.79.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.79.0...@standardnotes/snjs@2.79.1) (2022-03-10) + +### Bug Fixes + +* consecutive uploads ([#645](https://github.com/standardnotes/snjs/issues/645)) ([8955789](https://github.com/standardnotes/snjs/commit/895578938333376673fca4193c4ab89ef420e043)) + +# [2.79.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.78.0...@standardnotes/snjs@2.79.0) (2022-03-10) + +### Features + +* **filepicker:** multiple file selection ([#644](https://github.com/standardnotes/snjs/issues/644)) ([1bcdaf4](https://github.com/standardnotes/snjs/commit/1bcdaf4d2e05e1280ba8646683be71eebf95ee2d)) + +# [2.78.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.6...@standardnotes/snjs@2.78.0) (2022-03-09) + +### Features + +* export file operation type ([#643](https://github.com/standardnotes/snjs/issues/643)) ([ff5f136](https://github.com/standardnotes/snjs/commit/ff5f136655a8089a47c7eaa04e1e13e58852c93f)) + +## [2.77.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.5...@standardnotes/snjs@2.77.6) (2022-03-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.77.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.4...@standardnotes/snjs@2.77.5) (2022-03-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.77.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.3...@standardnotes/snjs@2.77.4) (2022-03-09) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.77.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.2...@standardnotes/snjs@2.77.3) (2022-03-09) + +### Bug Fixes + +* better error messages for unkown errors ([#640](https://github.com/standardnotes/snjs/issues/640)) ([04658c9](https://github.com/standardnotes/snjs/commit/04658c9d490a60830c21d8da4ff9f1e8ecff9527)) + +## [2.77.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.1...@standardnotes/snjs@2.77.2) (2022-03-08) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.77.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.77.0...@standardnotes/snjs@2.77.1) (2022-03-08) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.77.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.76.3...@standardnotes/snjs@2.77.0) (2022-03-08) + +### Features + +* determine files host dynamically ([#637](https://github.com/standardnotes/snjs/issues/637)) ([8ae8d32](https://github.com/standardnotes/snjs/commit/8ae8d32a2469cc6b5b42bfc68ec63200d6bc49ed)) + +## [2.76.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.76.2...@standardnotes/snjs@2.76.3) (2022-03-08) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.76.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.76.1...@standardnotes/snjs@2.76.2) (2022-03-07) + +### Bug Fixes + +* add isExperimentalFeature to ClientInterface ([#636](https://github.com/standardnotes/snjs/issues/636)) ([059a306](https://github.com/standardnotes/snjs/commit/059a306d78498a95f734130008b5eeca1a668352)) + +## [2.76.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.76.0...@standardnotes/snjs@2.76.1) (2022-03-07) + +### Bug Fixes + +* changes to enable experimental features ([#632](https://github.com/standardnotes/snjs/issues/632)) ([a60d981](https://github.com/standardnotes/snjs/commit/a60d981eda8f0634c0885cfcc0dfce0d02748072)) + +# [2.76.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.75.0...@standardnotes/snjs@2.76.0) (2022-03-07) + +### Features + +* integrity service ([#626](https://github.com/standardnotes/snjs/issues/626)) ([c5854fb](https://github.com/standardnotes/snjs/commit/c5854fb912dbe585516eeac3dde73573586c4e67)) + +# [2.75.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.74.2...@standardnotes/snjs@2.75.0) (2022-03-07) + +### Features + +* add renameFile function to itemManager ([#633](https://github.com/standardnotes/snjs/issues/633)) ([828f0d8](https://github.com/standardnotes/snjs/commit/828f0d8c79736b2ede1dd244e1e59569a88e6440)) + +## [2.74.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.74.1...@standardnotes/snjs@2.74.2) (2022-03-07) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.74.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.74.0...@standardnotes/snjs@2.74.1) (2022-03-07) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.74.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.73.2...@standardnotes/snjs@2.74.0) (2022-03-06) + +### Features + +* add un/protectFile and authorize[..]Files functions ([#631](https://github.com/standardnotes/snjs/issues/631)) ([fd39e40](https://github.com/standardnotes/snjs/commit/fd39e4056fa83f978b08451050da8585be882755)) + +## [2.73.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.73.1...@standardnotes/snjs@2.73.2) (2022-03-05) + +### Bug Fixes + +* handle listed returning homepage redirect as 200 ([0738ddb](https://github.com/standardnotes/snjs/commit/0738ddbef14f036f6436edf56a5d5616d1b69f41)) + +## [2.73.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.73.0...@standardnotes/snjs@2.73.1) (2022-03-04) + +### Bug Fixes + +* includes predicate ([#630](https://github.com/standardnotes/snjs/issues/630)) ([83fe4bf](https://github.com/standardnotes/snjs/commit/83fe4bfa7e24ccd68f92eb50ceee161a7253e9cf)) + +# [2.73.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.72.1...@standardnotes/snjs@2.73.0) (2022-03-04) + +### Features + +* add getFilesForNote function ([#629](https://github.com/standardnotes/snjs/issues/629)) ([abc6464](https://github.com/standardnotes/snjs/commit/abc646403539e3ca66e7702fe6a61f0f39c90c7b)) + +## [2.72.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.72.0...@standardnotes/snjs@2.72.1) (2022-03-04) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.72.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.71.1...@standardnotes/snjs@2.72.0) (2022-03-03) + +### Features + +* file to note relationships ([#627](https://github.com/standardnotes/snjs/issues/627)) ([bfcde87](https://github.com/standardnotes/snjs/commit/bfcde8764906b29ab6644d5b1a475fa61b950fee)) + +## [2.71.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.71.0...@standardnotes/snjs@2.71.1) (2022-03-02) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.71.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.70.0...@standardnotes/snjs@2.71.0) (2022-03-02) + +### Features + +* inject internal event bus to services for seamless event publishing ([#624](https://github.com/standardnotes/snjs/issues/624)) ([24b1e5c](https://github.com/standardnotes/snjs/commit/24b1e5c3e5ffe3c8ff228b97e91b83cb6c4077a5)) + +# [2.70.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.69.1...@standardnotes/snjs@2.70.0) (2022-03-02) + +### Features + +* add internal events handling between services ([#620](https://github.com/standardnotes/snjs/issues/620)) ([d982e36](https://github.com/standardnotes/snjs/commit/d982e365eda5268b6df339e9e0fe926a4808d86f)) + +## [2.69.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.69.0...@standardnotes/snjs@2.69.1) (2022-03-01) + +### Bug Fixes + +* map component after enabling experimental feature ([2dfa59e](https://github.com/standardnotes/snjs/commit/2dfa59e318e79ba9fadd42846b60f3be6437ccdf)) + +# [2.69.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.68.0...@standardnotes/snjs@2.69.0) (2022-03-01) + +### Features + +* ability to enable experimental features ([#623](https://github.com/standardnotes/snjs/issues/623)) ([4996a32](https://github.com/standardnotes/snjs/commit/4996a3288e4b89f69e40014775224c22ef37eb29)) + +# [2.68.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.5...@standardnotes/snjs@2.68.0) (2022-03-01) + +### Features + +* add experimental feature support ([#622](https://github.com/standardnotes/snjs/issues/622)) ([533c920](https://github.com/standardnotes/snjs/commit/533c92074826e496e6240dff2f11410f72064d8a)) + +## [2.67.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.4...@standardnotes/snjs@2.67.5) (2022-03-01) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.67.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.3...@standardnotes/snjs@2.67.4) (2022-02-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.67.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.2...@standardnotes/snjs@2.67.3) (2022-02-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.67.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.0...@standardnotes/snjs@2.67.2) (2022-02-28) + +### Bug Fixes + +* add pseudo change to get lerna to trigger ([41e6817](https://github.com/standardnotes/snjs/commit/41e6817bbf726b0932cdf16f58622328b9e42803)) + +## [2.67.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.67.0...@standardnotes/snjs@2.67.1) (2022-02-28) + +### Bug Fixes + +* add pseudo change to get lerna to trigger ([41e6817](https://github.com/standardnotes/snjs/commit/41e6817bbf726b0932cdf16f58622328b9e42803)) + +# [2.67.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.66.1...@standardnotes/snjs@2.67.0) (2022-02-28) + +### Features + +* extract responses package ([#617](https://github.com/standardnotes/snjs/issues/617)) ([1169747](https://github.com/standardnotes/snjs/commit/11697471052f94b37202df7fb560a79c5d65d573)) + +## [2.66.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.66.0...@standardnotes/snjs@2.66.1) (2022-02-28) + +### Bug Fixes + +* uploading file chunks as raw bytes in request ([#616](https://github.com/standardnotes/snjs/issues/616)) ([c1a431b](https://github.com/standardnotes/snjs/commit/c1a431b1f2427aafe6a6fc35adfd6e5102c2e177)) + +# [2.66.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.65.3...@standardnotes/snjs@2.66.0) (2022-02-27) + +### Features + +* untagged notes smart view ([#615](https://github.com/standardnotes/snjs/issues/615)) ([5b6a715](https://github.com/standardnotes/snjs/commit/5b6a7155243bbc86f1f24cd972f313d5de3811c2)) + +## [2.65.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.65.2...@standardnotes/snjs@2.65.3) (2022-02-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.65.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.65.1...@standardnotes/snjs@2.65.2) (2022-02-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.65.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.65.0...@standardnotes/snjs@2.65.1) (2022-02-26) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.65.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.64.0...@standardnotes/snjs@2.65.0) (2022-02-25) + +### Features + +* extract core functionalities to separate packages ([#610](https://github.com/standardnotes/snjs/issues/610)) ([801547a](https://github.com/standardnotes/snjs/commit/801547a71614ad51a92fb249eaa184ed46a44aac)) + +# [2.64.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.5...@standardnotes/snjs@2.64.0) (2022-02-25) + +### Features + +* files improvements ([#612](https://github.com/standardnotes/snjs/issues/612)) ([27a29a9](https://github.com/standardnotes/snjs/commit/27a29a98fdf966ddcbe93df951db1358848f6aab)) + +## [2.63.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.4...@standardnotes/snjs@2.63.5) (2022-02-24) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.63.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.2...@standardnotes/snjs@2.63.4) (2022-02-23) + +### Bug Fixes + +* **note:** prevents unknown types from breaking note's state when creating from PayloadContent ([#608](https://github.com/standardnotes/snjs/issues/608)) ([1734f6f](https://github.com/standardnotes/snjs/commit/1734f6f4026ed6f644e280a5fc18356f779e8ceb)) + +## [2.63.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.2...@standardnotes/snjs@2.63.3) (2022-02-23) + +### Bug Fixes + +* **note:** prevents unknown types from breaking note's state when creating from PayloadContent ([#608](https://github.com/standardnotes/snjs/issues/608)) ([1734f6f](https://github.com/standardnotes/snjs/commit/1734f6f4026ed6f644e280a5fc18356f779e8ceb)) + +## [2.63.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.1...@standardnotes/snjs@2.63.2) (2022-02-23) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.63.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.63.0...@standardnotes/snjs@2.63.1) (2022-02-22) + +### Bug Fixes + +* deprecated features should still be packaged ([#606](https://github.com/standardnotes/snjs/issues/606)) ([5176080](https://github.com/standardnotes/snjs/commit/5176080753a5720a0abf434923b1d8103b1261d0)) + +# [2.63.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.62.0...@standardnotes/snjs@2.63.0) (2022-02-22) + +### Features + +* extract services package ([#605](https://github.com/standardnotes/snjs/issues/605)) ([3966b10](https://github.com/standardnotes/snjs/commit/3966b10745c10ef5bb92871abb13ceb4ea631362)) + +# [2.62.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.5...@standardnotes/snjs@2.62.0) (2022-02-22) + +### Features + +* extract SNJS utils as a separate package ([#604](https://github.com/standardnotes/snjs/issues/604)) ([b28195c](https://github.com/standardnotes/snjs/commit/b28195c20be788eec8dabc44c5aff518f074cdd9)) + +## [2.61.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.4...@standardnotes/snjs@2.61.5) (2022-02-18) + +### Bug Fixes + +* set sync tokens after local processing completes so that if error occurs in between, next sync reattempts to fetch with previous tokens ([#602](https://github.com/standardnotes/snjs/issues/602)) ([355fb22](https://github.com/standardnotes/snjs/commit/355fb22c6e6fdd438734b43a5406e330b40951bb)) + +## [2.61.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.3...@standardnotes/snjs@2.61.4) (2022-02-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.61.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.2...@standardnotes/snjs@2.61.3) (2022-02-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.61.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.1...@standardnotes/snjs@2.61.2) (2022-02-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.61.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.61.0...@standardnotes/snjs@2.61.1) (2022-02-17) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.61.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.60.4...@standardnotes/snjs@2.61.0) (2022-02-16) + +### Features + +* application options in constructor params ([411be7c](https://github.com/standardnotes/snjs/commit/411be7c7f4f25aeae10f67b263296263ba1fda92)) + +## [2.60.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.60.3...@standardnotes/snjs@2.60.4) (2022-02-16) + +### Bug Fixes + +* readd on load decryption batching ([9e89229](https://github.com/standardnotes/snjs/commit/9e89229ca0724fe5fd2cd48ad4d1ffc2688709c0)) + +## [2.60.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.60.2...@standardnotes/snjs@2.60.3) (2022-02-16) + +### Bug Fixes + +* safer tag handling in hierarchy check ([3a8b376](https://github.com/standardnotes/snjs/commit/3a8b376b5d7f1d44e8a725fe4638dd64e35507ba)) + +## [2.60.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.60.1...@standardnotes/snjs@2.60.2) (2022-02-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.60.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.60.0...@standardnotes/snjs@2.60.1) (2022-02-16) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.60.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.8...@standardnotes/snjs@2.60.0) (2022-02-16) + +### Features + +* syncronous crypto ([#600](https://github.com/standardnotes/snjs/issues/600)) ([66496f6](https://github.com/standardnotes/snjs/commit/66496f6487630689b76eae6cd15bcb0c31e6b9cc)) + +## [2.59.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.7...@standardnotes/snjs@2.59.8) (2022-02-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.6...@standardnotes/snjs@2.59.7) (2022-02-16) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.5...@standardnotes/snjs@2.59.6) (2022-02-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.4...@standardnotes/snjs@2.59.5) (2022-02-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.3...@standardnotes/snjs@2.59.4) (2022-02-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.2...@standardnotes/snjs@2.59.3) (2022-02-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.1...@standardnotes/snjs@2.59.2) (2022-02-14) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.59.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.59.0...@standardnotes/snjs@2.59.1) (2022-02-14) + +### Bug Fixes + +* export ActionVerb from lib index instead of services ([#597](https://github.com/standardnotes/snjs/issues/597)) ([8eda762](https://github.com/standardnotes/snjs/commit/8eda762f6065762030c03478d170360ea5d2265b)) + +# [2.59.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.58.1...@standardnotes/snjs@2.59.0) (2022-02-14) + +### Features + +* improve hasRole function by checking role hierarchy ([#594](https://github.com/standardnotes/snjs/issues/594)) ([b4945e7](https://github.com/standardnotes/snjs/commit/b4945e794f8a0e47c1160751f4424ec7e904383b)) + +## [2.58.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.58.0...@standardnotes/snjs@2.58.1) (2022-02-12) + +### Bug Fixes + +* components getter should include themes ([c54ce9d](https://github.com/standardnotes/snjs/commit/c54ce9dbfa3efa30f4f51d0dd5c218337df24f62)) + +# [2.58.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.57.0...@standardnotes/snjs@2.58.0) (2022-02-11) + +### Features + +* add deleteRevision function ([#592](https://github.com/standardnotes/snjs/issues/592)) ([d3106ba](https://github.com/standardnotes/snjs/commit/d3106ba155a3b2ee52f20d488dae1630ff00196d)) + +# [2.57.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.56.1...@standardnotes/snjs@2.57.0) (2022-02-11) + +### Features + +* deprecated feature identifiers ([8e870f3](https://github.com/standardnotes/snjs/commit/8e870f352b022af768799a13473580d65150ff4b)) + +## [2.56.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.56.0...@standardnotes/snjs@2.56.1) (2022-02-10) + +### Bug Fixes + +* reuse existing tag notes count observers when recreating after resetting collection ([b726f3d](https://github.com/standardnotes/snjs/commit/b726f3d6b8f0b24eb57a72cb9114d4a4d53e6690)) + +# [2.56.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.55.0...@standardnotes/snjs@2.56.0) (2022-02-10) + +### Features + +* add hasRole function ([#591](https://github.com/standardnotes/snjs/issues/591)) ([b6d9e21](https://github.com/standardnotes/snjs/commit/b6d9e21b47d0d13d48f94a75214e4d1075092730)) + +# [2.55.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.54.1...@standardnotes/snjs@2.55.0) (2022-02-10) + +### Features + +* remove role name and subscription name from auth in favor of common ([14ec74c](https://github.com/standardnotes/snjs/commit/14ec74c3d98febf1117073f32442504cfad428e8)) + +## [2.54.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.54.0...@standardnotes/snjs@2.54.1) (2022-02-10) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.54.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.53.1...@standardnotes/snjs@2.54.0) (2022-02-09) + +### Features + +* use expanded tag title when searching ([cf277f2](https://github.com/standardnotes/snjs/commit/cf277f2ecf4fb1430ad7e822cc98f1f1c7dd0605)) + +## [2.53.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.53.0...@standardnotes/snjs@2.53.1) (2022-02-09) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.53.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.52.2...@standardnotes/snjs@2.53.0) (2022-02-09) + +### Features + +* add expanded property to tag ([6a682fe](https://github.com/standardnotes/snjs/commit/6a682feaa7df1c5cae200e197068be233b11930a)) + +## [2.52.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.52.1...@standardnotes/snjs@2.52.2) (2022-02-09) + +### Bug Fixes + +* improve application deinit; move callback to end to signify completion ([24e1bb8](https://github.com/standardnotes/snjs/commit/24e1bb83bcd350bd96e2c0b9040c9d674e92427e)) + +## [2.52.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.52.0...@standardnotes/snjs@2.52.1) (2022-02-08) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.52.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.51.3...@standardnotes/snjs@2.52.0) (2022-02-07) + +### Features + +* dynamic features based on runtime env ([#590](https://github.com/standardnotes/snjs/issues/590)) ([060861a](https://github.com/standardnotes/snjs/commit/060861a6c5bc179e3e1987c2b63490888e153bbb)) + +## [2.51.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.51.2...@standardnotes/snjs@2.51.3) (2022-02-07) + +### Bug Fixes + +* do not download offline repo if online subscription, as it may have conflicting info ([9e749c8](https://github.com/standardnotes/snjs/commit/9e749c89f3afb4aa6d3f973bc919fee66fd76790)) + +## [2.51.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.51.1...@standardnotes/snjs@2.51.2) (2022-02-07) + +### Bug Fixes + +* embedded safe text and title for note ([6313e77](https://github.com/standardnotes/snjs/commit/6313e77a5a155000382c37771ac2de981e8f6f64)) + +## [2.51.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.51.0...@standardnotes/snjs@2.51.1) (2022-02-04) + +### Bug Fixes + +* handle duplicated item history decryption ([#588](https://github.com/standardnotes/snjs/issues/588)) ([47de366](https://github.com/standardnotes/snjs/commit/47de3666f62af25040500bfe6ac4cb395a30d81f)) + +# [2.51.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.50.0...@standardnotes/snjs@2.51.0) (2022-02-04) + +### Features + +* ephemeral application identifier ([#587](https://github.com/standardnotes/snjs/issues/587)) ([502f5fe](https://github.com/standardnotes/snjs/commit/502f5fe99f798e0b486ac8c8ee07e94e5bd0fe53)) + +# [2.50.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.49.4...@standardnotes/snjs@2.50.0) (2022-02-04) + +### Features + +* listed account creation ([#586](https://github.com/standardnotes/snjs/issues/586)) ([488d0d2](https://github.com/standardnotes/snjs/commit/488d0d2642e764ff06a9d5358fe7f49f8863930e)) + +## [2.49.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.49.3...@standardnotes/snjs@2.49.4) (2022-02-02) + +### Bug Fixes + +* smart tag case insensitivity for strings ([#585](https://github.com/standardnotes/snjs/issues/585)) ([100d705](https://github.com/standardnotes/snjs/commit/100d705bb1cfbb04787aefca7603c78e74818284)) + +## [2.49.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.49.2...@standardnotes/snjs@2.49.3) (2022-02-02) + +### Bug Fixes + +* delete note should not be countable ([e2ef2fc](https://github.com/standardnotes/snjs/commit/e2ef2fcb21b44911973efa2da68e05f69154d53c)) + +## [2.49.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.49.1...@standardnotes/snjs@2.49.2) (2022-02-02) + +### Bug Fixes + +* no migration needed if tag already has parentId ([57a3887](https://github.com/standardnotes/snjs/commit/57a3887ac64a83847de003cbf95324f2c7eb2ac2)) + +## [2.49.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.49.0...@standardnotes/snjs@2.49.1) (2022-02-02) + +### Bug Fixes + +* return `NoUserSubscription` status when the third-party feature was not found ([#583](https://github.com/standardnotes/snjs/issues/583)) ([782ea88](https://github.com/standardnotes/snjs/commit/782ea8857ea43e1769281bd64f2c84f9dec9c178)) + +# [2.49.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.48.2...@standardnotes/snjs@2.49.0) (2022-02-02) + +### Features + +* add remaining icon types from library ([#584](https://github.com/standardnotes/snjs/issues/584)) ([393cb4e](https://github.com/standardnotes/snjs/commit/393cb4e9fa633337d16438eddeda72de4a4edbbc)) + +## [2.48.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.48.1...@standardnotes/snjs@2.48.2) (2022-02-02) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.48.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.48.0...@standardnotes/snjs@2.48.1) (2022-02-01) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.48.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.47.1...@standardnotes/snjs@2.48.0) (2022-02-01) + +### Features + +* tags to folders migration applicator ([c72134d](https://github.com/standardnotes/snjs/commit/c72134d5c520bfff23a2e9f9e7386d828eb7a46d)) + +## [2.47.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.47.0...@standardnotes/snjs@2.47.1) (2022-02-01) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.47.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.46.0...@standardnotes/snjs@2.47.0) (2022-02-01) + +### Features + +* add function to check if editor change requires alert ([#581](https://github.com/standardnotes/snjs/issues/581)) ([a9f27b1](https://github.com/standardnotes/snjs/commit/a9f27b18d17cb222425059440ddd84f82d2b4244)) + +# [2.46.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.44.3...@standardnotes/snjs@2.46.0) (2022-02-01) + +### Features + +* add new icon types ([#579](https://github.com/standardnotes/snjs/issues/579)) ([9f7c466](https://github.com/standardnotes/snjs/commit/9f7c466734849fe0066314b369603dc0faa10123)) +* implement migration for tag folders ([#561](https://github.com/standardnotes/snjs/issues/561)) ([4ec6d91](https://github.com/standardnotes/snjs/commit/4ec6d9158c850a6ceb887d7c40e58751989bece8)) + +# [2.45.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.44.3...@standardnotes/snjs@2.45.0) (2022-01-31) + +### Features + +* add new icon types ([#579](https://github.com/standardnotes/snjs/issues/579)) ([9f7c466](https://github.com/standardnotes/snjs/commit/9f7c466734849fe0066314b369603dc0faa10123)) + +## [2.44.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.44.2...@standardnotes/snjs@2.44.3) (2022-01-31) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.44.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.44.1...@standardnotes/snjs@2.44.2) (2022-01-31) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.44.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.44.0...@standardnotes/snjs@2.44.1) (2022-01-31) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.44.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.6...@standardnotes/snjs@2.44.0) (2022-01-31) + +### Features + +* store editor icons and their colors in snjs ([#573](https://github.com/standardnotes/snjs/issues/573)) ([1932d8d](https://github.com/standardnotes/snjs/commit/1932d8deb9309f22744d2ec53fd46b805874e738)) + +## [2.43.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.5...@standardnotes/snjs@2.43.6) (2022-01-29) + +### Bug Fixes + +* remove sncomponent as actions extension subclass due to fact singleton strategies differ ([#577](https://github.com/standardnotes/snjs/issues/577)) ([2b54377](https://github.com/standardnotes/snjs/commit/2b54377e2f9d0d95e40a4f5c3d6976b592e8a212)) + +## [2.43.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.4...@standardnotes/snjs@2.43.5) (2022-01-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.43.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.3...@standardnotes/snjs@2.43.4) (2022-01-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.43.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.2...@standardnotes/snjs@2.43.3) (2022-01-28) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.43.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.1...@standardnotes/snjs@2.43.2) (2022-01-27) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.43.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.43.0...@standardnotes/snjs@2.43.1) (2022-01-27) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.43.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.42.2...@standardnotes/snjs@2.43.0) (2022-01-26) + +### Features + +* add SNS & SQS setup for subscription events ([#572](https://github.com/standardnotes/snjs/issues/572)) ([e706ec3](https://github.com/standardnotes/snjs/commit/e706ec36673eb94204b9d88938a69248906e8586)) + +## [2.42.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.42.1...@standardnotes/snjs@2.42.2) (2022-01-25) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.42.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.42.0...@standardnotes/snjs@2.42.1) (2022-01-25) + +### Bug Fixes + +* refactor tag parent reference ([#560](https://github.com/standardnotes/snjs/issues/560)) ([c1a5ba7](https://github.com/standardnotes/snjs/commit/c1a5ba73b03263c914c042fdbee7efd367a3cd09)) + +# [2.42.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.41.1...@standardnotes/snjs@2.42.0) (2022-01-22) + +### Bug Fixes + +* automatically delete component duplicates based on identifier ([#566](https://github.com/standardnotes/snjs/issues/566)) ([eb161cd](https://github.com/standardnotes/snjs/commit/eb161cd07b9586cc4f2bbc59709ce590f16f75df)) + +### Features + +* add migration to remove NoDistraction theme ([#511](https://github.com/standardnotes/snjs/issues/511)) ([7a0a870](https://github.com/standardnotes/snjs/commit/7a0a870c4742fad5440f1f7d68e84d3d519f1b6e)) + +## [2.41.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.41.0...@standardnotes/snjs@2.41.1) (2022-01-21) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.41.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.7...@standardnotes/snjs@2.41.0) (2022-01-19) + +### Features + +* onPresyncSave ([0e5f451](https://github.com/standardnotes/snjs/commit/0e5f451e85cfd7cb58ff1f0a87425e4dd43f6030)) + +## [2.40.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.6...@standardnotes/snjs@2.40.7) (2022-01-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.5...@standardnotes/snjs@2.40.6) (2022-01-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.4...@standardnotes/snjs@2.40.5) (2022-01-19) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.3...@standardnotes/snjs@2.40.4) (2022-01-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.2...@standardnotes/snjs@2.40.3) (2022-01-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.1...@standardnotes/snjs@2.40.2) (2022-01-18) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.40.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.40.0...@standardnotes/snjs@2.40.1) (2022-01-18) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.40.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.39.0...@standardnotes/snjs@2.40.0) (2022-01-17) + +### Features + +* tag notes index ([#546](https://github.com/standardnotes/snjs/issues/546)) ([ab12281](https://github.com/standardnotes/snjs/commit/ab122817648f3ac3a7c51ef66a0609f5521300ec)) + +# [2.39.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.38.4...@standardnotes/snjs@2.39.0) (2022-01-17) + +### Features + +* Add preferences for automatic themes ([#558](https://github.com/standardnotes/snjs/issues/558)) ([ab4936e](https://github.com/standardnotes/snjs/commit/ab4936ea2520dc2bb0e71086a6581078507581f8)) + +## [2.38.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.38.3...@standardnotes/snjs@2.38.4) (2022-01-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.38.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.38.2...@standardnotes/snjs@2.38.3) (2022-01-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.38.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.38.1...@standardnotes/snjs@2.38.2) (2022-01-17) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.38.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.38.0...@standardnotes/snjs@2.38.1) (2022-01-16) + +### Bug Fixes + +* add payload source constructor to sync components on launch ([ddb79cc](https://github.com/standardnotes/snjs/commit/ddb79ccee8512523e8d0241310039be84f08cfc0)) + +# [2.38.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.37.3...@standardnotes/snjs@2.38.0) (2022-01-15) + +### Features + +* remove server extension type ([#552](https://github.com/standardnotes/snjs/issues/552)) ([aa542f3](https://github.com/standardnotes/snjs/commit/aa542f3124c60fa81a0b271030b3c35415c54a62)) + +## [2.37.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.37.2...@standardnotes/snjs@2.37.3) (2022-01-15) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.37.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.37.1...@standardnotes/snjs@2.37.2) (2022-01-14) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.37.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.37.0...@standardnotes/snjs@2.37.1) (2022-01-14) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.37.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.36.0...@standardnotes/snjs@2.37.0) (2022-01-14) + +### Features + +* spellcheck control per note ([#557](https://github.com/standardnotes/snjs/issues/557)) ([76cc637](https://github.com/standardnotes/snjs/commit/76cc63781e3568bbb48b44001ab51644af0985b3)) + +# [2.36.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.6...@standardnotes/snjs@2.36.0) (2022-01-14) + +### Features + +* move sncrypto packages to snjs monorepo ([#554](https://github.com/standardnotes/snjs/issues/554)) ([db83991](https://github.com/standardnotes/snjs/commit/db8399190d9d10fdc31060568b836c62933fd525)) + +## [2.35.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.5...@standardnotes/snjs@2.35.6) (2022-01-14) + +### Bug Fixes + +* cross-package dependencies and content type imports ([#556](https://github.com/standardnotes/snjs/issues/556)) ([26ba1e0](https://github.com/standardnotes/snjs/commit/26ba1e0c38e7e0ae572996125150a4c3d27f1c0a)) + +## [2.35.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.3...@standardnotes/snjs@2.35.5) (2022-01-13) + +### Bug Fixes + +* remove readonly qualifier ([a4a06eb](https://github.com/standardnotes/snjs/commit/a4a06eb738f013c4bd76b9dc8a1e8466d492ccea)) +* **snjs:** handle case where tag is no longer available ([87b43c8](https://github.com/standardnotes/snjs/commit/87b43c865f4f3a734db35c2ce9e58a07a03d125e)) + +## [2.35.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.3...@standardnotes/snjs@2.35.4) (2022-01-12) + +### Bug Fixes + +* remove readonly qualifier ([a4a06eb](https://github.com/standardnotes/snjs/commit/a4a06eb738f013c4bd76b9dc8a1e8466d492ccea)) +* **snjs:** handle case where tag is no longer available ([87b43c8](https://github.com/standardnotes/snjs/commit/87b43c865f4f3a734db35c2ce9e58a07a03d125e)) + +## [2.35.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.2...@standardnotes/snjs@2.35.3) (2022-01-12) + +### Bug Fixes + +* make extension-server url dynamic, depending on whether the env is dev or prod ([#550](https://github.com/standardnotes/snjs/issues/550)) ([f28b564](https://github.com/standardnotes/snjs/commit/f28b56400564cd8cb03777489b8ddacbefe9bb9d)) + +## [2.35.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.1...@standardnotes/snjs@2.35.2) (2022-01-12) + +### Bug Fixes + +* updating fake settings tests ([#549](https://github.com/standardnotes/snjs/issues/549)) ([e5f7742](https://github.com/standardnotes/snjs/commit/e5f7742cc7146c8d63b10ebfd0a4479b98924b90)) + +## [2.35.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.35.0...@standardnotes/snjs@2.35.1) (2022-01-11) + +### Bug Fixes + +* default tag to empty string if title undefined ([908fbaf](https://github.com/standardnotes/snjs/commit/908fbafd39600165b8d516826cc1dd5488075be6)) + +# [2.35.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.34.3...@standardnotes/snjs@2.35.0) (2022-01-11) + +### Features + +* extract save method from clients into note_view_controller ([1692935](https://github.com/standardnotes/snjs/commit/169293517488f210e544640aac1702209a01d760)) + +## [2.34.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.34.2...@standardnotes/snjs@2.34.3) (2022-01-10) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.34.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.34.1...@standardnotes/snjs@2.34.2) (2022-01-07) + +### Bug Fixes + +* add isNativeFeature application function ([1350866](https://github.com/standardnotes/snjs/commit/1350866a021201795c2aef10c2276ca7be42267f)) + +## [2.34.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.34.0...@standardnotes/snjs@2.34.1) (2022-01-06) + +### Bug Fixes + +* sync after deleting offline repo ([29df45a](https://github.com/standardnotes/snjs/commit/29df45a8b018d216b3b2ed6671b494966e97fc17)) + +# [2.34.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.33.0...@standardnotes/snjs@2.34.0) (2022-01-06) + +### Features + +* extracted note controllers from web app ([0d2c615](https://github.com/standardnotes/snjs/commit/0d2c615a16ec632e2abe4b3762605b7f5d30cf34)) + +# [2.33.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.32.0...@standardnotes/snjs@2.33.0) (2022-01-06) + +### Features + +* Add NotesHideEditorIcon pref key ([#539](https://github.com/standardnotes/snjs/issues/539)) ([636cc2a](https://github.com/standardnotes/snjs/commit/636cc2ab75ddf900edcc04a4124da5bcac7cdb22)) + +# [2.32.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.25...@standardnotes/snjs@2.32.0) (2022-01-06) + +### Features + +* expose urls needed for cloud backup integration ([#537](https://github.com/standardnotes/snjs/issues/537)) ([efa492f](https://github.com/standardnotes/snjs/commit/efa492f6ddd26e23b590150c00263ac14267f572)) + +## [2.31.25](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.24...@standardnotes/snjs@2.31.25) (2022-01-05) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.24](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.23...@standardnotes/snjs@2.31.24) (2022-01-04) + +### Bug Fixes + +* workaround for RN localeCompare crash ([d19fee5](https://github.com/standardnotes/snjs/commit/d19fee5133596bde8966315511b0cedb3d52ffdd)) + +## [2.31.23](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.22...@standardnotes/snjs@2.31.23) (2022-01-03) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.22](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.21...@standardnotes/snjs@2.31.22) (2022-01-03) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.21](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.20...@standardnotes/snjs@2.31.21) (2022-01-03) + +### Bug Fixes + +* allow url override for component viewer factory function ([014067e](https://github.com/standardnotes/snjs/commit/014067e9050927de104b5e7e35f8e3f7596fa84b)) + +## [2.31.20](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.19...@standardnotes/snjs@2.31.20) (2022-01-02) + +### Bug Fixes + +* use absolute path for native web components ([1b4e1dc](https://github.com/standardnotes/snjs/commit/1b4e1dcac144c0d68cf7c25b9b6f6694305d030a)) + +## [2.31.19](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.18...@standardnotes/snjs@2.31.19) (2022-01-02) + +### Bug Fixes + +* check source before syncing components ([1fd1e8b](https://github.com/standardnotes/snjs/commit/1fd1e8b5f9b25ea13a0f75cc433c7b137ff4ea63)) + +## [2.31.18](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.17...@standardnotes/snjs@2.31.18) (2021-12-31) + +### Bug Fixes + +* handle null tag title ([c5f0c40](https://github.com/standardnotes/snjs/commit/c5f0c401bc1739f1ac27b9dfe44ee60fe0f8e948)) + +## [2.31.17](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.16...@standardnotes/snjs@2.31.17) (2021-12-30) + +### Bug Fixes + +* web component path ([64fc4c6](https://github.com/standardnotes/snjs/commit/64fc4c6efebd8119ada88b71919ea4fd0c4f777a)) + +## [2.31.16](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.15...@standardnotes/snjs@2.31.16) (2021-12-30) + +### Bug Fixes + +* component path ([8e544cb](https://github.com/standardnotes/snjs/commit/8e544cbe72aa1cc4395b56f2106f80c0941b7c33)) + +## [2.31.15](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.14...@standardnotes/snjs@2.31.15) (2021-12-30) + +### Bug Fixes + +* map remote features to static features ([1e81dcc](https://github.com/standardnotes/snjs/commit/1e81dcc9ea2f86b4853ec543a7efbca5ed50228e)) + +## [2.31.14](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.13...@standardnotes/snjs@2.31.14) (2021-12-30) + +### Bug Fixes + +* exclude server items from integrity hash ([67dfad4](https://github.com/standardnotes/snjs/commit/67dfad40394a338283a1d622c42633012bc7b9c6)) + +## [2.31.13](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.12...@standardnotes/snjs@2.31.13) (2021-12-30) + +### Bug Fixes + +* no url error for native component ([7ae4409](https://github.com/standardnotes/snjs/commit/7ae4409bc7cf5fffe94d8a5ddb9eb51eb93ed2e5)) + +## [2.31.12](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.11...@standardnotes/snjs@2.31.12) (2021-12-30) + +### Bug Fixes + +* web components path ([26d24d7](https://github.com/standardnotes/snjs/commit/26d24d7e3ed0e77a9b15106fa15e1b6ed6c008ce)) + +## [2.31.11](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.10...@standardnotes/snjs@2.31.11) (2021-12-30) + +### Bug Fixes + +* **snjs:** component viewer correct var check ([f66c45a](https://github.com/standardnotes/snjs/commit/f66c45aff1f741502e215cb7bcdb51c8e800fa93)) + +## [2.31.10](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.9...@standardnotes/snjs@2.31.10) (2021-12-30) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.9](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.8...@standardnotes/snjs@2.31.9) (2021-12-30) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.8](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.7...@standardnotes/snjs@2.31.8) (2021-12-30) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.7](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.6...@standardnotes/snjs@2.31.7) (2021-12-30) + +### Bug Fixes + +* linter errors in features package ([#534](https://github.com/standardnotes/snjs/issues/534)) ([640012e](https://github.com/standardnotes/snjs/commit/640012e2aa0d956b9a7ffd92a1435e9bd072229f)) + +## [2.31.6](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.5...@standardnotes/snjs@2.31.6) (2021-12-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.5](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.4...@standardnotes/snjs@2.31.5) (2021-12-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.4](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.3...@standardnotes/snjs@2.31.4) (2021-12-29) + +**Note:** Version bump only for package @standardnotes/snjs + +## [2.31.3](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.2...@standardnotes/snjs@2.31.3) (2021-12-29) + +### Bug Fixes + +* remove code coverage reports from repository ([61f5dfd](https://github.com/standardnotes/snjs/commit/61f5dfd8e9698e36142df131ad210749865f70f4)) + +## [2.31.2](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.1...@standardnotes/snjs@2.31.2) (2021-12-29) + +### Bug Fixes + +* correct gitignore paths ([cefc0cf](https://github.com/standardnotes/snjs/commit/cefc0cfcf98e3e5378e055b8c46931b53b23195e)) +* include dist in static components ([d17ce0f](https://github.com/standardnotes/snjs/commit/d17ce0f67045c6e4c97bf4577709aa58794e72e6)) + +## [2.31.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.31.0...@standardnotes/snjs@2.31.1) (2021-12-29) + +**Note:** Version bump only for package @standardnotes/snjs + +# [2.31.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.30.0...@standardnotes/snjs@2.31.0) (2021-12-29) + +### Features + +* native components ([#525](https://github.com/standardnotes/snjs/issues/525)) ([ca7f8e8](https://github.com/standardnotes/snjs/commit/ca7f8e876bccdaff60daf2cdee3d3b2020954e53)) + +# [2.30.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.29.1...@standardnotes/snjs@2.30.0) (2021-12-28) + +### Features + +* smart tags native support ([#504](https://github.com/standardnotes/snjs/issues/504)) ([4b5349c](https://github.com/standardnotes/snjs/commit/4b5349c8a3f2d9837ec6cd4c8fe009b7eccb5264)) + +## [2.29.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.29.0...@standardnotes/snjs@2.29.1) (2021-12-27) + +### Bug Fixes + +* safe access to potentially undefined references array ([#529](https://github.com/standardnotes/snjs/issues/529)) ([63f5cdd](https://github.com/standardnotes/snjs/commit/63f5cdd74780b33e32b97b6a1c1aae81ede76275)) + +# [2.29.0](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.28.1...@standardnotes/snjs@2.29.0) (2021-12-24) + +### Features + +* component-viewer ([#507](https://github.com/standardnotes/snjs/issues/507)) ([462d0db](https://github.com/standardnotes/snjs/commit/462d0db6261e610bc72cc86f0086dc6e5b1ed0d6)) + +## [2.28.1](https://github.com/standardnotes/snjs/compare/@standardnotes/snjs@2.28.0...@standardnotes/snjs@2.28.1) (2021-12-23) + +**Note:** Version bump only for package @standardnotes/snjs + +# 2.28.0 (2021-12-23) + +### Features + +* rename email backup setting to email backup frequency ([25e7b46](https://github.com/standardnotes/snjs/commit/25e7b4620834711ac7f513ae893898c5eab1af53)) + +## 2.27.3 (2021-12-23) + +### Bug Fixes + +* lock package versions ([8aa2ce6](https://github.com/standardnotes/snjs/commit/8aa2ce676b57598ab72840adf851869d8e769022)) + +## 2.27.2 (2021-12-23) + +### Bug Fixes + +* add publishing from package version by lerna ([80433d0](https://github.com/standardnotes/snjs/commit/80433d044f258095753482b8322d73aba3d9a9e4)) + +## 2.27.1 (2021-12-23) + +### Bug Fixes + +* remove the ammend commit from lerna versioning ([f0400d9](https://github.com/standardnotes/snjs/commit/f0400d9a2f5a04eaece2e4c16da71166a2ddb251)) + +# 2.27.0 (2021-12-23) + +### Features + +* add one drive backup frequency setting ([#522](https://github.com/standardnotes/snjs/issues/522)) ([c27827f](https://github.com/standardnotes/snjs/commit/c27827f8c7969dd32511c9c75122ece372132c83)) + +## 2.26.4 (2021-12-23) + +### Bug Fixes + +* remove running tests upon deployment - ensured on PR status checks ([#523](https://github.com/standardnotes/snjs/issues/523)) ([5c795d1](https://github.com/standardnotes/snjs/commit/5c795d17b583d02955773576384e622c3ef7f418)) + +## 2.26.3 (2021-12-23) + +### Bug Fixes + +* pr template ([#518](https://github.com/standardnotes/snjs/issues/518)) ([b445bb6](https://github.com/standardnotes/snjs/commit/b445bb64841217ae27c2514887629235be95d2a3)) + +## 2.26.2 (2021-12-23) + +### Bug Fixes + +* checkout with personal access token ([773c1ef](https://github.com/standardnotes/snjs/commit/773c1ef91c4452ad411e928342060dcb59428e3c)) + +## 2.26.1 (2021-12-22) + +### Bug Fixes + +* gpg signing with CI StandardNotes user ([d72f61c](https://github.com/standardnotes/snjs/commit/d72f61c23cd15b31d37340cc756d16526634b9ee)) + +# 2.26.0 (2021-12-22) + +### Bug Fixes + +* add ability to return descendants for children tags ([0d8f00f](https://github.com/standardnotes/snjs/commit/0d8f00fd77c60a90c61d96124db980e736217dce)) +* add check for udnefined live item removeObserver ([c7a79e6](https://github.com/standardnotes/snjs/commit/c7a79e63415417d41057b9291d6149153cbfb131)) +* add demo server host ([74cf862](https://github.com/standardnotes/snjs/commit/74cf862ec5ad4b41f9f31b90dc5775391575ed87)) +* add dev extensions server to trusted hosts for demo user ([69a0b2c](https://github.com/standardnotes/snjs/commit/69a0b2c1035167f5b6af0663be4ee7f2868d9697)) +* add error log ([8c852df](https://github.com/standardnotes/snjs/commit/8c852df745fd530ea2ae9ed539c8836b976eca4a)) +* add localhost to first party hosts ([1c72eb7](https://github.com/standardnotes/snjs/commit/1c72eb7c507f28c5161e88b0c67c642a54bc281e)) +* add new props to features type ([#458](https://github.com/standardnotes/snjs/issues/458)) ([7a3e911](https://github.com/standardnotes/snjs/commit/7a3e9114414646a564a4de0879d68a801686e34f)) +* add numeric option to localeCompare ([1acc9a0](https://github.com/standardnotes/snjs/commit/1acc9a070cc58a782956e0ca2bf94a7af302ff9b)) +* add support for undefined data ([28ef0e1](https://github.com/standardnotes/snjs/commit/28ef0e1beb7077f8b16f6519bc518a07c380d695)) +* add support for undefined data ([64d3916](https://github.com/standardnotes/snjs/commit/64d3916e8536688e7fea805ba6e1c695c4b72b32)) +* add support for undefined data ([b68b3d6](https://github.com/standardnotes/snjs/commit/b68b3d6888b749cafc345a8c84c4146d0b835e0d)) +* add title for 2fa challenge ([14ae979](https://github.com/standardnotes/snjs/commit/14ae97947284d4dbfa84ca1b9a0ad409d52d5202)) +* apply payload filter to all remote sources ([#309](https://github.com/standardnotes/snjs/issues/309)) ([5028146](https://github.com/standardnotes/snjs/commit/50281469a19a160ad2708618cc34ce55c02e4cb7)) +* better log error message ([5c93338](https://github.com/standardnotes/snjs/commit/5c93338483d303ba1e8c91a95d49a397065d4e54)) +* bump snjs version ([eb41547](https://github.com/standardnotes/snjs/commit/eb415471eca68e80345271c1d18207baa6dd5ac4)) +* catch websocket connection error ([#440](https://github.com/standardnotes/snjs/issues/440)) ([1fcb59a](https://github.com/standardnotes/snjs/commit/1fcb59a163ecf9485f2e43788f7e330ebaf2f391)) +* change lock to prevent editing ([ff2a336](https://github.com/standardnotes/snjs/commit/ff2a3365e2825aff9cce286b56021afe4f9ddbba)) +* change migration version ([d51710b](https://github.com/standardnotes/snjs/commit/d51710b218e36afee14eed639c12b18b64c17973)) +* change permission and role types, handle promises and fix tests ([ac97009](https://github.com/standardnotes/snjs/commit/ac9700999e1092e83f56bbfb35aacaf918905669)) +* change request verb for retry request ([8e7a133](https://github.com/standardnotes/snjs/commit/8e7a133ee15bcf3c03a7d7f72d64d6bf688b3fd0)) +* cleanup test output ([#414](https://github.com/standardnotes/snjs/issues/414)) ([b8b126a](https://github.com/standardnotes/snjs/commit/b8b126a247fff13bb15cbf342969a9c0dcf0022b)) +* collection duplicate handling performance ([8e0d2e6](https://github.com/standardnotes/snjs/commit/8e0d2e6e71e6714a5cff2d9bcc074add34b29c17)) +* compile error ([383991b](https://github.com/standardnotes/snjs/commit/383991b01f6e5cd43815ddeaf86cba537eb257a6)) +* compile errors ([c2c76db](https://github.com/standardnotes/snjs/commit/c2c76db224a54d69b9c7513ddc228edde302c047)) +* conditionally map components only if change ([a90f23b](https://github.com/standardnotes/snjs/commit/a90f23b241acd066f6979bd19f150331b1e5a5c9)) +* do not cancel already canceled challenge ([2441802](https://github.com/standardnotes/snjs/commit/2441802020011f5a1ed403907fd69a2bc3d3e4d8)) +* do not duplicate action extensions ([8697fee](https://github.com/standardnotes/snjs/commit/8697feedf29fd6a7f89f644f66ef4fa1cde462d9)) +* do not store actions relevant to an item ([#475](https://github.com/standardnotes/snjs/issues/475)) ([4945978](https://github.com/standardnotes/snjs/commit/494597845235f01fc7d227f9f037e6a7d916d5db)) +* don't access document unless confirmed in web env ([f33dc43](https://github.com/standardnotes/snjs/commit/f33dc430947be6e501bcb52813f1b071421eeb53)) +* duplicate items allowed in collection; more modelManager => payloadManager ([312ba4a](https://github.com/standardnotes/snjs/commit/312ba4a794b18766c9a0d32fdfe3f4a517de0eaa)) +* eagerly load preferences ([#227](https://github.com/standardnotes/snjs/issues/227)) ([24bb988](https://github.com/standardnotes/snjs/commit/24bb98854b75dd6f93097237d3d6e34c279f747e)) +* enable v4 features by default ([f5ab907](https://github.com/standardnotes/snjs/commit/f5ab907a4ccb7e11eeaf0f34d6fdbf5d94e5e82e)) +* export ItemMessagePayload ([#291](https://github.com/standardnotes/snjs/issues/291)) ([aa19bd6](https://github.com/standardnotes/snjs/commit/aa19bd65207a53ddeae4f22c1f6549758490dc3c)) +* extensions with no valid_until should not be marked as expired ([5b4a6a2](https://github.com/standardnotes/snjs/commit/5b4a6a29e0538f3515ddb2e1c61c34e45390bdb0)) +* fallback to data.error if response error is undefined ([5e3b432](https://github.com/standardnotes/snjs/commit/5e3b4322876511a09d910176d4f5b14ed44e4a9d)) +* feature status should be not entitled if no account or offline repo ([6ffa362](https://github.com/standardnotes/snjs/commit/6ffa362ec05de2133ca8c0bc5c68672862c9c10b)) +* features timestamp precision ([#449](https://github.com/standardnotes/snjs/issues/449)) ([101e383](https://github.com/standardnotes/snjs/commit/101e383597c8bb457fc8288c4f1950c691ef8c1d)) +* fix get parent chain logic ([c1d82bd](https://github.com/standardnotes/snjs/commit/c1d82bd738755fe2193c9eedc884f3ae76424ffc)) +* fix lint error ([319b95a](https://github.com/standardnotes/snjs/commit/319b95ab6b94698192687f3d7d1f82455ae36340)) +* fix lint problems and permission name export ([d303c06](https://github.com/standardnotes/snjs/commit/d303c06a0bda0d44a01d67c3fdb3a495a6a9148c)) +* fix sync response in offline operation ([ef94377](https://github.com/standardnotes/snjs/commit/ef943777161acb8b91b3b9d1b165400539851593)) +* fix types ([f346bea](https://github.com/standardnotes/snjs/commit/f346bea1b65389a24dafdbc7c0ba4e41e162ab50)) +* fix types errors ([e11fd3f](https://github.com/standardnotes/snjs/commit/e11fd3fdb5541815d480ae7e5426a23673c3e82b)) +* fix types errors ([ce27820](https://github.com/standardnotes/snjs/commit/ce278203c1f1ec211433c86b1c7a616de09e1878)) +* function signature ([f45cb33](https://github.com/standardnotes/snjs/commit/f45cb3327f6c5d0ec3da1bbcfda624afccda8a86)) +* handle case where legacy passcode params are missing version ([bfd9bd2](https://github.com/standardnotes/snjs/commit/bfd9bd29c0d8e5ce3faf74d1d2cc8971c3fb62ab)) +* handle multipage sync with items key uuid altneration ([49c411a](https://github.com/standardnotes/snjs/commit/49c411af9364e34e8b1cc5af5511aaa2d07c3070)) +* handle new api errors ([76e84f8](https://github.com/standardnotes/snjs/commit/76e84f8e68aa57059fcf6407180dae745cf13f86)) +* handle offline status ([#491](https://github.com/standardnotes/snjs/issues/491)) ([7f371b0](https://github.com/standardnotes/snjs/commit/7f371b0391041e7b1c82cd78b3d6816e553d3614)) +* handle server rejected payloads ([#442](https://github.com/standardnotes/snjs/issues/442)) ([45b12e5](https://github.com/standardnotes/snjs/commit/45b12e5b05ae19f0234a9c27a1e327dd7fa0f028)) +* import case ([070c7b9](https://github.com/standardnotes/snjs/commit/070c7b9a6db906fc8e2fd3809d02485f378773a0)) +* import location ([c23a48b](https://github.com/standardnotes/snjs/commit/c23a48bb8991c4b5b464d33f772b212329b9f070)) +* import location ([e11c07a](https://github.com/standardnotes/snjs/commit/e11c07aa40d618c6ba7c119c7bc1137d45963bc3)) +* import location ([f0d080c](https://github.com/standardnotes/snjs/commit/f0d080c7a79498e1cdea5d6df3dfd05a173e2a9b)) +* improvements to history logic; only track revisions that are different from the current value ([d74de44](https://github.com/standardnotes/snjs/commit/d74de442e12aab208bebc19d729d8729ce0f4f2c)) +* issue where importing backup offline causes key confusion ([e9934e2](https://github.com/standardnotes/snjs/commit/e9934e2c1b9afe912294e3f3da6d1f4bd0df41cc)) +* key params origination when changing email ([#423](https://github.com/standardnotes/snjs/issues/423)) ([a4954d0](https://github.com/standardnotes/snjs/commit/a4954d0ed4e7c7b2f1939192ec4bc54427badcdb)) +* leave only item manager observer ([#431](https://github.com/standardnotes/snjs/issues/431)) ([9deeb7c](https://github.com/standardnotes/snjs/commit/9deeb7c24e34458de8689a4638d498a696de5928)) +* lenient feature status check until features are retrieved ([#490](https://github.com/standardnotes/snjs/issues/490)) ([4d0bed5](https://github.com/standardnotes/snjs/commit/4d0bed5f8b630d7fb9ee8545e211e1e0d5b292ab)) +* make getProtectionSessionExpiryDate sync ([#220](https://github.com/standardnotes/snjs/issues/220)) ([cb0363a](https://github.com/standardnotes/snjs/commit/cb0363a66233662cb459820d0817dba3de218302)) +* make unprotecting a note a protected action ([#226](https://github.com/standardnotes/snjs/issues/226)) ([1f3c096](https://github.com/standardnotes/snjs/commit/1f3c096f2c52873044ccfd9a5dcb1cd4389c3db2)) +* mark feature mapped items as needing save ([3b1d0f4](https://github.com/standardnotes/snjs/commit/3b1d0f4869b2b234e475bbc680398e6eea662a5c)) +* mark migrations as complete under mocked credentials ([70283cf](https://github.com/standardnotes/snjs/commit/70283cff0a4b8955e6a6b84c469b684279b35241)) +* mark updated_at as deprecated ([2ebec25](https://github.com/standardnotes/snjs/commit/2ebec2585f41a673c43b52f2af7942e11dfd787c)) +* missing export from lib/index.ts ([#293](https://github.com/standardnotes/snjs/issues/293)) ([3f6223b](https://github.com/standardnotes/snjs/commit/3f6223be7b85e5ff5827a555d24bbb5900d82431)) +* missing key params property ([1d62034](https://github.com/standardnotes/snjs/commit/1d62034f6d97b355c097330c4c4ff9a36cf6c99d)) +* more nuanced version detection ([3dfcd7f](https://github.com/standardnotes/snjs/commit/3dfcd7f08ac45350193163bbb6493030e54fe751)) +* more strict component mapping ([#447](https://github.com/standardnotes/snjs/issues/447)) ([bb53ffc](https://github.com/standardnotes/snjs/commit/bb53ffc6935f84cf34e081f3192e6c194a034ab9)) +* move offline features to be item based ([#478](https://github.com/standardnotes/snjs/issues/478)) ([62ba641](https://github.com/standardnotes/snjs/commit/62ba641045c92805251485b4b51a6c5d8ea7b90a)) +* no api requests before app launch ([#424](https://github.com/standardnotes/snjs/issues/424)) ([7391813](https://github.com/standardnotes/snjs/commit/73918133ff6ab8eea1b8d73ff5d395acc05bca7c)) +* no key params if decrypted export ([#421](https://github.com/standardnotes/snjs/issues/421)) ([d21d651](https://github.com/standardnotes/snjs/commit/d21d6510dde5b2bae01881b01a6bc612fe96b2bb)) +* no session entries for newly inserted items ([caae9c5](https://github.com/standardnotes/snjs/commit/caae9c5ae39edf6f6e31d700f924c18a425e2a4f)) +* notify components of any change regardless if source is not LocalChanged ([4b27ec5](https://github.com/standardnotes/snjs/commit/4b27ec52c8b77cc4f4cd64653738f5b8a7681369)) +* pass previousRevision directly into conflicting function ([2a7ba30](https://github.com/standardnotes/snjs/commit/2a7ba30cd17afb03ce96c1ca0fabd91ee125d7cc)) +* payload url structure for ext repo ([#441](https://github.com/standardnotes/snjs/issues/441)) ([87f844b](https://github.com/standardnotes/snjs/commit/87f844bb7bc90188339179ba3415215c609c6bfc)) +* persist host after signing in ([#474](https://github.com/standardnotes/snjs/issues/474)) ([1b2437a](https://github.com/standardnotes/snjs/commit/1b2437a839321659fcd0f8807a6c52b123768523)) +* PR comments ([4e68c75](https://github.com/standardnotes/snjs/commit/4e68c75cf353764599f215587c58b4475e4067f9)) +* PR comments ([e19ca08](https://github.com/standardnotes/snjs/commit/e19ca088e8563b87828ccb50562e7c6989f8b61f)) +* prevent ChallengeReason number overlap ([b68152d](https://github.com/standardnotes/snjs/commit/b68152df356313b74e9e42cc31a8377e59d4c048)) +* prioritize synced keys when multiple defaults ([#448](https://github.com/standardnotes/snjs/issues/448)) ([b761d8b](https://github.com/standardnotes/snjs/commit/b761d8b2edb5a7515e2f17e1d5d3016613f3903d)) +* race condition where item could be dirtied at same time as sync began, thus previously omitting it from syncing again ([6c87b9a](https://github.com/standardnotes/snjs/commit/6c87b9a8f005a0a6da6663eef79587bb6cd81089)) +* re-expose SNApiservice.apiVersion to tests ([7a77dd7](https://github.com/standardnotes/snjs/commit/7a77dd7390d26231dedfb623257fd46945fde717)) +* read data on success handle ([490a7bc](https://github.com/standardnotes/snjs/commit/490a7bc242076242f06c9dc8e68cfdcc3d4dc5a3)) +* read data on success handle ([26875f4](https://github.com/standardnotes/snjs/commit/26875f4b419dff15620c1b3cab180bbb9df945be)) +* read error from response instead of data ([c02e7b0](https://github.com/standardnotes/snjs/commit/c02e7b0eae7c4b381b21279b0a6932ab39e378e2)) +* read roles instead of role from websocket message ([b9db3b4](https://github.com/standardnotes/snjs/commit/b9db3b48f733621eea07c09627d835c17a54a7fc)) +* read single revision from data ([7d09b24](https://github.com/standardnotes/snjs/commit/7d09b2419556259f204b96b92f0c40d15845b174)) +* remove extra undefined check ([17943bf](https://github.com/standardnotes/snjs/commit/17943bf701eafbf52f76ade72a03991ea3e969e6)) +* remove permission name type export ([5da6039](https://github.com/standardnotes/snjs/commit/5da6039cd23b26e917ddcdba8d558ac7d66c67d8)) +* remove PermissionName export ([9db5beb](https://github.com/standardnotes/snjs/commit/9db5beb76d74ae7e950409035293cafadc673c95)) +* remove whitespace ([a3e3dd1](https://github.com/standardnotes/snjs/commit/a3e3dd149558535fe05494820eb340d90a64f9f2)) +* rename purchase tokens to subscription tokens ([#453](https://github.com/standardnotes/snjs/issues/453)) ([b787300](https://github.com/standardnotes/snjs/commit/b787300df7c7ecacf88bfd9d1290790521510a53)) +* resync unsynced items keys on app launch/first sync completion ([#364](https://github.com/standardnotes/snjs/issues/364)) ([76d96ba](https://github.com/standardnotes/snjs/commit/76d96bacd26c10e3b067fc50a19943893b706980)) +* return only displayable items for components ([6949f8a](https://github.com/standardnotes/snjs/commit/6949f8ac7ca4b8a670e5e67e23f6614e571a8c06)) +* return valid feature status for offline subscribers ([0bf3a4c](https://github.com/standardnotes/snjs/commit/0bf3a4cb265906b6e211982f2b0bd59d40768e2e)) +* save loaded value after first revision ([14674d2](https://github.com/standardnotes/snjs/commit/14674d2234dd1fd1b3160ebf55fd508e67a72ce8)) +* set minimum passcode length ([8caa5e4](https://github.com/standardnotes/snjs/commit/8caa5e458714a044ee6290a5bed07d56c163421d)) +* set new root key before items keys are saved so that items keys … ([#486](https://github.com/standardnotes/snjs/issues/486)) ([3f37435](https://github.com/standardnotes/snjs/commit/3f37435b2070fe9d2bdd0f68bdb5d8c401859905)) +* set placeholders for account password and application passcode ([#219](https://github.com/standardnotes/snjs/issues/219)) ([50e7e26](https://github.com/standardnotes/snjs/commit/50e7e2658c20fe120d3ebf78210689275a232450)) +* tests ([42a3e9a](https://github.com/standardnotes/snjs/commit/42a3e9a08ff0effddf07ad9ee7cb05ff92df981e)) +* third party feature status ([#485](https://github.com/standardnotes/snjs/issues/485)) ([23e524c](https://github.com/standardnotes/snjs/commit/23e524c53d7b287a19fb52f06975738bfbc4b748)) +* types ([eac9647](https://github.com/standardnotes/snjs/commit/eac96470207de95ce996b9772d113177fc2b66b1)) +* update ssjs to the latest version ([#397](https://github.com/standardnotes/snjs/issues/397)) ([de1ef59](https://github.com/standardnotes/snjs/commit/de1ef591d35334c450dea0f2fb8b1924b7305ca3)) +* use cached features until server request is made ([#502](https://github.com/standardnotes/snjs/issues/502)) ([bf9a78c](https://github.com/standardnotes/snjs/commit/bf9a78c824b2c0d66b6fc3180d78b48f4541e70e)) +* use correct 002 cost, increase sessions test safety margin ([67bb932](https://github.com/standardnotes/snjs/commit/67bb932f5609483694fd5c6938ec9ee0a8e5db76)) +* use correct challenge reason for CloudLink access ([057ac5b](https://github.com/standardnotes/snjs/commit/057ac5bbf22617cc3137afa2b43ded2aa8fec40a)) +* use correct host in request URIs ([0eeaee2](https://github.com/standardnotes/snjs/commit/0eeaee27480f54ed5649a797976bee24c2c97ed5)) +* use correct key for 004 backup but 003 item ([#281](https://github.com/standardnotes/snjs/issues/281)) ([ec00d50](https://github.com/standardnotes/snjs/commit/ec00d5093372b1e9a2fa4704a26818015de88a09)) +* use correct placeholder for passcode prompt ([3a0a095](https://github.com/standardnotes/snjs/commit/3a0a09502d7823cd35dbfa42474b3ca3a542ac23)) +* use created_at for unsynced session history entries ([#305](https://github.com/standardnotes/snjs/issues/305)) ([fef99a8](https://github.com/standardnotes/snjs/commit/fef99a882659ee3e8c578ef6dcd2cb5192098fc6)) +* use isNullOrUndefined instead of `!` ([ce56b02](https://github.com/standardnotes/snjs/commit/ce56b02b0857579f2823fdf818b439ede22405e6)) +* use KeepBase in conflict strategy instead of returning empty array ([ee0bdc1](https://github.com/standardnotes/snjs/commit/ee0bdc1753811d04dfb8844d952e9b688368a4bc)) +* use tag uuid for filtering in searchTags ([d05488d](https://github.com/standardnotes/snjs/commit/d05488d70b68e8815dd0bd87914c72826aa4eed8)) +* use updated_at_timestamp for integrity hash; abort if missing fo… ([#308](https://github.com/standardnotes/snjs/issues/308)) ([d511871](https://github.com/standardnotes/snjs/commit/d51187188a4af0918f3caf29baaba2cf244f92d6)) +* validate extension urls ([2972649](https://github.com/standardnotes/snjs/commit/2972649d68ad8985da52837ead43131ae3f2e27e)) +* versioning and package dependencies ([#509](https://github.com/standardnotes/snjs/issues/509)) ([fe1df94](https://github.com/standardnotes/snjs/commit/fe1df94eff3e90bcf9ba0cf45bdc44ac49204c71)) +* **ci:** check types during lint step ([90949ec](https://github.com/standardnotes/snjs/commit/90949ec1c787c7a05e7aea9121882a61c2658842)) +* util signature ([f2ec366](https://github.com/standardnotes/snjs/commit/f2ec366d3e9f7bf388a3b5c9561c88bacbb99531)) +* utils documentMode types ([f965d39](https://github.com/standardnotes/snjs/commit/f965d39a2cea3b64df6c51c218f9fc193ab142e1)) +* wrong version in api call url ([1ba45cc](https://github.com/standardnotes/snjs/commit/1ba45cc40453329e66c36b196bb7b62838f7dc45)) + +### Features + +* add "Disabled" option to email backup frequency ([#499](https://github.com/standardnotes/snjs/issues/499)) ([90a56ac](https://github.com/standardnotes/snjs/commit/90a56aca2b64556dc98bc0b9eeef1982c88411cd)) +* add addTagHierarchyToNote & isTemplateItem ([#496](https://github.com/standardnotes/snjs/issues/496)) ([8c9a56d](https://github.com/standardnotes/snjs/commit/8c9a56dc244378ecae6fc37c3fb691fe0f2d4660)) +* add addTagHierarchyToNote & isTemplateItem ([#500](https://github.com/standardnotes/snjs/issues/500)) ([5afc381](https://github.com/standardnotes/snjs/commit/5afc381a19fcfeab233674452725b7b5615be8da)) +* add alternative host and switch sign out to API v1 ([#319](https://github.com/standardnotes/snjs/issues/319)) ([8ddf189](https://github.com/standardnotes/snjs/commit/8ddf18952e93c35e9281ad44341f537bb46289f2)) +* add CloudLink access as a protected action ([ea6ffab](https://github.com/standardnotes/snjs/commit/ea6ffab9b15e6eef98606c575599627f916cdde6)) +* add config package with common configuration files ([#210](https://github.com/standardnotes/snjs/issues/210)) ([da023dd](https://github.com/standardnotes/snjs/commit/da023dd2d3568b972c5eed259067375bb0505669)) +* add domain-events package ([#208](https://github.com/standardnotes/snjs/issues/208)) ([0bed8d9](https://github.com/standardnotes/snjs/commit/0bed8d9d64d164ce51acaf0c5eca4f16c4bad846)) +* add email changing ([#412](https://github.com/standardnotes/snjs/issues/412)) ([5c752c9](https://github.com/standardnotes/snjs/commit/5c752c92cdee211656a180ca6c7e12d8cf222a5f)) +* add enableV4 flag ([#422](https://github.com/standardnotes/snjs/issues/422)) ([eeaba09](https://github.com/standardnotes/snjs/commit/eeaba09145360e1b460cada46fc0cc5320deda36)) +* Add Focus Mode feature ([#487](https://github.com/standardnotes/snjs/issues/487)) ([7f22590](https://github.com/standardnotes/snjs/commit/7f2259003e150db83f0182c9878493ce775360cc)) +* add function to return password creation date for user ([#417](https://github.com/standardnotes/snjs/issues/417)) ([4c8dd30](https://github.com/standardnotes/snjs/commit/4c8dd304246ba75192b0c36ab6180768f0bfe34b)) +* add get user subscription api call ([#411](https://github.com/standardnotes/snjs/issues/411)) ([83a8518](https://github.com/standardnotes/snjs/commit/83a8518f9232ca3d84dd5ddb102fb6f8b5448654)) +* add getLaunchChallenge method ([baed48d](https://github.com/standardnotes/snjs/commit/baed48dfce03e9fe2bf022e81f3a475787455878)) +* Add getNewSubscriptionToken() function ([#461](https://github.com/standardnotes/snjs/issues/461)) ([eb70754](https://github.com/standardnotes/snjs/commit/eb707545a5236e8bb892828ec35e150d1f145445)) +* add getParentTags method ([ff09e91](https://github.com/standardnotes/snjs/commit/ff09e91307e760e65212a0b4829b681488b40e83)) +* add getSortedTagsForNote method ([34b40e5](https://github.com/standardnotes/snjs/commit/34b40e5743684cb993981529511c61f6ccc7acfa)) +* add getTagDescendants method ([4ba3dd4](https://github.com/standardnotes/snjs/commit/4ba3dd4c49e8c122a08a4ce42c2364ae41c39fda)) +* add isSameDay util ([2e79590](https://github.com/standardnotes/snjs/commit/2e79590b1313258741d4623a5cfa0b101ba50e69)) +* add naturalSort util and tests ([9f6fd34](https://github.com/standardnotes/snjs/commit/9f6fd34bbdfbcd2b0717cab99e4e7404569401fa)) +* add nonerrored param for getItems ([314d8fe](https://github.com/standardnotes/snjs/commit/314d8fefeb49f4e906f02148fcd7c1f061c03972)) +* add notification on set features ([#493](https://github.com/standardnotes/snjs/issues/493)) ([aa0625e](https://github.com/standardnotes/snjs/commit/aa0625ee2bf3e5ca27fbce550a4a8b84305ae3ef)) +* add parameter to make authorized encrypted backups ([818cc47](https://github.com/standardnotes/snjs/commit/818cc47f3dd2e5e053553c14978dbb56b078b862)) +* Add pref keys for ShowDeleted and HideProtected ([682b3e8](https://github.com/standardnotes/snjs/commit/682b3e85c51280411dc2627a0af100a040e3b4ef)) +* add screenshot privacy storage key type ([0332693](https://github.com/standardnotes/snjs/commit/03326939a32991558b80998ffa3190b8fce60f60)) +* add searching protected contents to protections ([5fa90d7](https://github.com/standardnotes/snjs/commit/5fa90d7323ade7168d8ec75833dadde60fd2ba9d)) +* add searchTags method ([2633cca](https://github.com/standardnotes/snjs/commit/2633cca6a41f61a8a6d477f37c8f3f3516be28b5)) +* add select protected note challenge reason ([ee28a93](https://github.com/standardnotes/snjs/commit/ee28a9390e114207965e841acbc7125659cb438a)) +* add session expiry events & timers ([#221](https://github.com/standardnotes/snjs/issues/221)) ([f048984](https://github.com/standardnotes/snjs/commit/f0489842d4af3f0fad5eab1a33ef98f8360c58a4)) +* add SN|Privileges to content types ([#444](https://github.com/standardnotes/snjs/issues/444)) ([0eee358](https://github.com/standardnotes/snjs/commit/0eee3581e5f9f41f227c824adc92a0e15b8fa4b4)) +* Add test for includeProtected ([03a9452](https://github.com/standardnotes/snjs/commit/03a9452a60fa4c55f16b5a497737c71112fdad04)) +* Click component action ([#298](https://github.com/standardnotes/snjs/issues/298)) ([b2d183f](https://github.com/standardnotes/snjs/commit/b2d183f15bfa4211abb2784b24eb8d4239c2fc3b)) +* Component isDeprecated property ([#342](https://github.com/standardnotes/snjs/issues/342)) ([f5deb91](https://github.com/standardnotes/snjs/commit/f5deb91d7888fff9065094e9ae4b52942a9bd96f)) +* credential service to centralize account and passcode operations ([8ad836a](https://github.com/standardnotes/snjs/commit/8ad836a6b2d1234201a4292c877d9be894c7dda2)) +* destroy revoked session data ([#198](https://github.com/standardnotes/snjs/issues/198)) ([432941a](https://github.com/standardnotes/snjs/commit/432941aa97b988210acc977f1e41a2359dfb62f6)) +* do not allow decrypted payloads from server ([#265](https://github.com/standardnotes/snjs/issues/265)) ([c441eb4](https://github.com/standardnotes/snjs/commit/c441eb49684b533d42af3872036c4a259183527b)) +* do not conflict if server and client previously had same value ([adea12e](https://github.com/standardnotes/snjs/commit/adea12ea4043dde127ca5ccd1ffbefc7ae683772)) +* dont alternate uuids on sign in ([852bca2](https://github.com/standardnotes/snjs/commit/852bca23f58c65c6a68925e8cd11ccf355295899)) +* enable revoking other sessions ([#435](https://github.com/standardnotes/snjs/issues/435)) ([5c687e3](https://github.com/standardnotes/snjs/commit/5c687e32ec0a17404d1a7b5d48a0e12427cf3798)) +* expose authorizeSearchingProtectedNotesText() to clients ([97cee70](https://github.com/standardnotes/snjs/commit/97cee70101143359ea484940409bbeaf70f0920c)) +* extension key user setting ([#394](https://github.com/standardnotes/snjs/issues/394)) ([1f99995](https://github.com/standardnotes/snjs/commit/1f99995ece7a122c0587ad6385ee2c9a7b3e2f7e)) +* features instead of permissions ([#385](https://github.com/standardnotes/snjs/issues/385)) ([b53e967](https://github.com/standardnotes/snjs/commit/b53e967297bc472ed11aed79af79d0ae5b36d101)) +* get feature status ([#482](https://github.com/standardnotes/snjs/issues/482)) ([bfb2c9a](https://github.com/standardnotes/snjs/commit/bfb2c9a6329ab323094bc496723011fe3c4b93c3)) +* get features for offline users and store the corresponding data in device's storage for later reuse ([#464](https://github.com/standardnotes/snjs/issues/464)) ([8865caf](https://github.com/standardnotes/snjs/commit/8865caf5e151ac0a72f2d07185d07b95cf33aa19)) +* get purchase flow url ([#446](https://github.com/standardnotes/snjs/issues/446)) ([839fc9e](https://github.com/standardnotes/snjs/commit/839fc9e8775b4fe16de6acac1342f865bd5a0910)) +* handle next version host setting ([df58583](https://github.com/standardnotes/snjs/commit/df585836fa0eaf62fa4d87f0a0bc106f3fe0c35a)) +* handle password retrieval for decrypting import files ([#212](https://github.com/standardnotes/snjs/issues/212)) ([7bd0af4](https://github.com/standardnotes/snjs/commit/7bd0af408a732935ffd2418281a0cbbcde22c7e2)) +* handle undefaulting multiple items keys ([75adafc](https://github.com/standardnotes/snjs/commit/75adafcc8b2f1a5cc7f62e87f672c0c25b46ad44)) +* hide note contents if the protected note is open and the protection expires and the user didn't edit the note in previous 30 seconds ([#483](https://github.com/standardnotes/snjs/issues/483)) ([7ecd7bd](https://github.com/standardnotes/snjs/commit/7ecd7bd42d5f6df4d343a8fedb99a8978296d0e7)) +* Implement note filter for protected notes ([2354011](https://github.com/standardnotes/snjs/commit/2354011965834df2df09e54000afcb37508dc5ed)) +* Implement SNMfaService ([#392](https://github.com/standardnotes/snjs/issues/392)) ([8737665](https://github.com/standardnotes/snjs/commit/8737665a7f74a40e991dc600aaacfcc0db197f0a)) +* improve protections ([#217](https://github.com/standardnotes/snjs/issues/217)) ([64e509e](https://github.com/standardnotes/snjs/commit/64e509e312c73cbbea894fb543e11de0b7d4796c)) +* include notes that have tag titles that match search query ([#471](https://github.com/standardnotes/snjs/issues/471)) ([73c232f](https://github.com/standardnotes/snjs/commit/73c232fb65276253e47ebff60812e53f1bf77ed9)) +* KeyPressed component action ([#294](https://github.com/standardnotes/snjs/issues/294)) ([2420ad1](https://github.com/standardnotes/snjs/commit/2420ad1db3b6b10e5d7c331202041fae6c66cf1b)) +* KeyUp component action ([#295](https://github.com/standardnotes/snjs/issues/295)) ([d48c2e6](https://github.com/standardnotes/snjs/commit/d48c2e69b2ca902cfc7dd80186e0aedc67839c7f)) +* make disabling mfa a protected action ([#429](https://github.com/standardnotes/snjs/issues/429)) ([17027be](https://github.com/standardnotes/snjs/commit/17027be321a04718c4672ce768fbfe71be701394)) +* make tag titles case insensitive ([9b8d9d4](https://github.com/standardnotes/snjs/commit/9b8d9d478c689c0f866c869d3a01d908682729c0)) +* make webSocketUrl optional ([2c11fab](https://github.com/standardnotes/snjs/commit/2c11fab5a61193e7829cca38030249be01b0d787)) +* map only some features to items ([#404](https://github.com/standardnotes/snjs/issues/404)) ([7f521ef](https://github.com/standardnotes/snjs/commit/7f521efe621d3f2128881aed8a31bf7bd2399a74)) +* method to get purchase page url ([#439](https://github.com/standardnotes/snjs/issues/439)) ([331ecd9](https://github.com/standardnotes/snjs/commit/331ecd9d408c687a1999ce623cc87564d1d57068)) +* mfa feature availability ([#415](https://github.com/standardnotes/snjs/issues/415)) ([6f81ef1](https://github.com/standardnotes/snjs/commit/6f81ef1e84425e7f880794955baab2ed91370ce1)) +* migrate changePassword to api/v1 ([7c6feee](https://github.com/standardnotes/snjs/commit/7c6feee5d0d0270edff30e40caa707845f6d2102)) +* migrate changePassword to api/v1 ([b9ad53b](https://github.com/standardnotes/snjs/commit/b9ad53b33ebdbe8b0b8b358610e4c4ee1222a286)) +* migrate deleteSession to api/v1 ([3a4b88f](https://github.com/standardnotes/snjs/commit/3a4b88f496f2b5904f05732090555525184aed19)) +* migrate getAccountKeyParams to api/v1 ([0a2be91](https://github.com/standardnotes/snjs/commit/0a2be91ffd852f1f6e455c01c2fbf2f125d7dc03)) +* migrate getItemRevisions to api/v1 ([50dc479](https://github.com/standardnotes/snjs/commit/50dc479685c5132d4ddddc2c72728e9114cc2ff7)) +* migrate getRevision to api/v1 ([3fefe72](https://github.com/standardnotes/snjs/commit/3fefe72011ec3872fd52479e270ac49400c33977)) +* migrate getSessions to api/v1 ([39e8e77](https://github.com/standardnotes/snjs/commit/39e8e776ea663dafc9d0fd5afa2fef029a128986)) +* migrate refreshSession to api/v1 ([187fde6](https://github.com/standardnotes/snjs/commit/187fde658c2d340db8ede852f31b73f4b444786f)) +* migrate register to api/v1 ([6d7f96c](https://github.com/standardnotes/snjs/commit/6d7f96cfef23975bb6a05d3233de5197db71cff1)) +* migrate signIn to api/v1 ([203995e](https://github.com/standardnotes/snjs/commit/203995efb83dc29c043376a18d056b7fb9233c13)) +* migrate sync to api/v1 ([68417dc](https://github.com/standardnotes/snjs/commit/68417dc25f2a62e213ea50813b8566de0c6e8dda)) +* more liberal session history spacing ([0fa6aae](https://github.com/standardnotes/snjs/commit/0fa6aaec076d79de73bba0cd84837b5d96aeaf0b)) +* more tests around remote retrieved expectations ([219f01c](https://github.com/standardnotes/snjs/commit/219f01c7fcb44748851425c24229d549fd6bc329)) +* new methods for multiple selection ([1650de9](https://github.com/standardnotes/snjs/commit/1650de966d341745f2df7898cff070b82d0e3125)) +* no-restricted-globals on history ([9a94a75](https://github.com/standardnotes/snjs/commit/9a94a753947141ad7aa2dc8c8f4f76c677576572)) +* notes display criteria ([#232](https://github.com/standardnotes/snjs/issues/232)) ([51f3f07](https://github.com/standardnotes/snjs/commit/51f3f075a87a18ba4826f5b44693458115685e2a)) +* notify on user roles changed ([201d582](https://github.com/standardnotes/snjs/commit/201d5825df4786287e7994f504192b2f882394fb)) +* pass numeric param as true when sorting items by title ([08af846](https://github.com/standardnotes/snjs/commit/08af846dc2f8d5b3f7a93133e6bf00b2725afd65)) +* persist timestamps from server ([#273](https://github.com/standardnotes/snjs/issues/273)) ([f4b774c](https://github.com/standardnotes/snjs/commit/f4b774c7ef3b9791a85640c4fc24ef647a57bfd5)) +* protect passcode creation ([#241](https://github.com/standardnotes/snjs/issues/241)) ([e4c1828](https://github.com/standardnotes/snjs/commit/e4c1828c54a7eaacd42c62794e4738edabd4d686)) +* protections public methods ([#225](https://github.com/standardnotes/snjs/issues/225)) ([f55bc6b](https://github.com/standardnotes/snjs/commit/f55bc6b87008dab2d3e29368633447811c46240c)) +* Remove "No Distraction" as it's replaced by Focused Writing ([#506](https://github.com/standardnotes/snjs/issues/506)) ([527e676](https://github.com/standardnotes/snjs/commit/527e67673652721ebc947c752815c12007f8d263)) +* remove batch manager ([f138215](https://github.com/standardnotes/snjs/commit/f1382151f4ff1018dd3027af7afff314d52f1437)) +* remove ContentType from features in favor of common ([#402](https://github.com/standardnotes/snjs/issues/402)) ([ba0ac62](https://github.com/standardnotes/snjs/commit/ba0ac62fd2631541cd0aa615e9163af1b59fd824)) +* remove legacy mfa ([#495](https://github.com/standardnotes/snjs/issues/495)) ([b0498f4](https://github.com/standardnotes/snjs/commit/b0498f4fad85367e1b57c6deacb5d313331bf8db)) +* remove nextVersionHost ([5a39738](https://github.com/standardnotes/snjs/commit/5a39738897cfbec32d51c4376d0a3ada09664224)) +* restrict batch streaming permissions ([#315](https://github.com/standardnotes/snjs/issues/315)) ([c98bf7d](https://github.com/standardnotes/snjs/commit/c98bf7dde6ae1118c11c688837317c8d477e1889)) +* roles and permissions handling ([610b689](https://github.com/standardnotes/snjs/commit/610b689f7e03ab98029fe73d9b13b3944952c828)) +* send snjs and environment versions in API call headers ([#400](https://github.com/standardnotes/snjs/issues/400)) ([39f59ba](https://github.com/standardnotes/snjs/commit/39f59ba69be8c50897284886581f79888e110d84)) +* settings service ([#384](https://github.com/standardnotes/snjs/issues/384)) ([c9c976f](https://github.com/standardnotes/snjs/commit/c9c976f15f158ce121f53341dc74f91beb27e6a2)) +* show a confirmation dialog when signing out and there are unsaved changes ([#348](https://github.com/standardnotes/snjs/issues/348)) ([5a8b070](https://github.com/standardnotes/snjs/commit/5a8b0701bd42338b12f26b8646b049023b5603dd)) +* show sign out message in singular for one unsynced item ([125c6af](https://github.com/standardnotes/snjs/commit/125c6af58522b8e2bc1f40d923ff96a1bb982eb0)) +* SNApplication.hasProtections() ([#224](https://github.com/standardnotes/snjs/issues/224)) ([4a9d3b1](https://github.com/standardnotes/snjs/commit/4a9d3b1bdd1637b1df9bf64247a0e79998be3182)) +* sort tags and match delimiter separated components ([59cdbb8](https://github.com/standardnotes/snjs/commit/59cdbb8a1715752d61c7daed7e2cb65ee4c7197d)) +* store user features ([#403](https://github.com/standardnotes/snjs/issues/403)) ([9dde72f](https://github.com/standardnotes/snjs/commit/9dde72f31f3028662723e773b1c434c00fa06946)) +* tag folders ([#489](https://github.com/standardnotes/snjs/issues/489)) ([fc934ae](https://github.com/standardnotes/snjs/commit/fc934ae7e5714c8f40830c36a53af934e7c38072)) +* ThemesActivated component action ([#257](https://github.com/standardnotes/snjs/issues/257)) ([facb3dd](https://github.com/standardnotes/snjs/commit/facb3ddb78e7c4d531e833e3dbb0f270737d1623)) +* turn snjs into a monorepo ([#207](https://github.com/standardnotes/snjs/issues/207)) ([30efca8](https://github.com/standardnotes/snjs/commit/30efca8f29e73d6e298f70fa7a08c1a724d03576)) +* update links to .com ([cc50f87](https://github.com/standardnotes/snjs/commit/cc50f877ee2c7b88f4922a0207ffceaeefb760e9)) +* update services to latest versions ([#395](https://github.com/standardnotes/snjs/issues/395)) ([8082ebd](https://github.com/standardnotes/snjs/commit/8082ebddfed288563fe8a63c4a1999090c33f280)) +* upgrade node engine versions to latest active LTS ([#462](https://github.com/standardnotes/snjs/issues/462)) ([686fc15](https://github.com/standardnotes/snjs/commit/686fc15030d302b474ebb7ef1cd4dcc48ec42359)) +* use naturalSort for searchTags and use regex flag instead of .toLowerCase() ([5ec634e](https://github.com/standardnotes/snjs/commit/5ec634e0eac06e3899ece9eea7fb25d01fbb9d50)) +* use regex and add note parameter for searchTags ([f915379](https://github.com/standardnotes/snjs/commit/f915379e6693c977428f14f3564dd74cc4dea30c)) +* **wip:** auth & permissions ([4e49eff](https://github.com/standardnotes/snjs/commit/4e49eff5c520b67cb3a975626b8700737cb8ee01)) + +### Reverts + +* 7ecd7bd42d5f6df4d343a8fedb99a8978296d0e7 ([8485dc8](https://github.com/standardnotes/snjs/commit/8485dc8e0c6e0c72d459632c7671a3061a21f86e)) +* 8c9a56dc244378ecae6fc37c3fb691fe0f2d4660 ([9d316a1](https://github.com/standardnotes/snjs/commit/9d316a11a1d4f906cfa772598841dcd68c98f557)) +* Revert "refactor: make features depend on snjs" ([c0dfdc1](https://github.com/standardnotes/snjs/commit/c0dfdc110de5b73967fb9b3a3d93ba4cf872d24f)) +* Revert "Present-value sync (#277)" ([1225073](https://github.com/standardnotes/snjs/commit/1225073766f8bca5d69f8e86e92586f0f9018510)), closes [#277](https://github.com/standardnotes/snjs/issues/277) +* Revert "Revert "Merge pull request #239 from standardnotes/search-refactor"" ([cddae38](https://github.com/standardnotes/snjs/commit/cddae3877b06b3d3d67a3109619a9a0a8d5c6679)), closes [#239](https://github.com/standardnotes/snjs/issues/239) diff --git a/packages/snjs/babel.config.js b/packages/snjs/babel.config.js new file mode 100644 index 000000000..a466c86cd --- /dev/null +++ b/packages/snjs/babel.config.js @@ -0,0 +1,7 @@ +module.exports = function (api) { + api.cache.forever(); + + return { + presets: ['@babel/preset-env'], + }; +}; diff --git a/packages/snjs/jest-global.ts b/packages/snjs/jest-global.ts new file mode 100644 index 000000000..b07f9ca0a --- /dev/null +++ b/packages/snjs/jest-global.ts @@ -0,0 +1,2 @@ +//@ts-ignore +global['__VERSION__'] = global['SnjsVersion'] = require('./package.json').version diff --git a/packages/snjs/jest.config.js b/packages/snjs/jest.config.js new file mode 100644 index 000000000..d3e0f68ed --- /dev/null +++ b/packages/snjs/jest.config.js @@ -0,0 +1,37 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const base = require('../../node_modules/@standardnotes/config/src/jest.json'); + +module.exports = { + ...base, + moduleNameMapper: { + '@Lib/(.*)': '/lib/$1', + '@Services/(.*)': '/lib/Services/$1', + }, + globals: { + 'ts-jest': { + tsconfig: '/lib/tsconfig.json', + isolatedModules: true, + babelConfig: 'babel.config.js', + }, + }, + clearMocks: true, + collectCoverageFrom: ['lib/**/{!(index),}.ts'], + coverageDirectory: 'coverage', + coverageReporters: ['json', 'text', 'html'], + resetMocks: true, + resetModules: true, + roots: ['/lib'], + setupFiles: ['/jest-global.ts'], + setupFilesAfterEnv: [], + transform: { + '^.+\\.(ts|js)?$': 'ts-jest', + }, + coverageThreshold: { + global: { + branches: 13, + functions: 22, + lines: 27, + statements: 28, + }, + }, +} diff --git a/packages/snjs/jsdoc.json b/packages/snjs/jsdoc.json new file mode 100644 index 000000000..368911169 --- /dev/null +++ b/packages/snjs/jsdoc.json @@ -0,0 +1,22 @@ +{ + "source": { + "includePattern": ".+\\.js(doc|x)?$", + "include": ["lib"], + "exclude": ["node_modules"] + }, + "recurseDepth": 10, + "opts": { + "destination": "./docs/", + "recurse": true, + "template": "node_modules/docdash" + }, + "tags": { + "allowUnknownTags": true, + "dictionaries": ["jsdoc", "closure"] + }, + "docdash": { + "meta": { + "title": "SNJS Documentation" + } + } +} diff --git a/packages/snjs/lib/Application/Application.spec.ts b/packages/snjs/lib/Application/Application.spec.ts new file mode 100644 index 000000000..f8d8bfe84 --- /dev/null +++ b/packages/snjs/lib/Application/Application.spec.ts @@ -0,0 +1,151 @@ +import { SNLog } from './../Log' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { + AlertService, + DeviceInterface, + Environment, + namespacedKey, + Platform, + RawStorageKey, +} from '@standardnotes/services' +import { SNApplication } from './Application' + +describe('application', () => { + // eslint-disable-next-line no-console + SNLog.onLog = console.log + SNLog.onError = console.error + + let application: SNApplication + let device: DeviceInterface + let crypto: PureCryptoInterface + + beforeEach(async () => { + const identifier = '123' + + crypto = {} as jest.Mocked + crypto.initialize = jest.fn() + + device = {} as jest.Mocked + device.openDatabase = jest.fn().mockResolvedValue(true) + device.getAllRawDatabasePayloads = jest.fn().mockReturnValue([]) + device.setRawStorageValue = jest.fn() + device.getRawStorageValue = jest.fn().mockImplementation((key) => { + if (key === namespacedKey(identifier, RawStorageKey.SnjsVersion)) { + return '10.0.0' + } + return undefined + }) + device.getDatabaseKeys = async () => { + return Promise.resolve(['1', '2', '3']) + } + + application = new SNApplication({ + environment: Environment.Mobile, + platform: Platform.Ios, + deviceInterface: device, + crypto: crypto, + alertService: {} as jest.Mocked, + identifier: identifier, + defaultHost: 'localhost', + appVersion: '1.0', + }) + + await application.prepareForLaunch({ receiveChallenge: jest.fn() }) + }) + + it('diagnostics', async () => { + const diagnostics = await application.getDiagnostics() + + expect(diagnostics).toEqual( + expect.objectContaining({ + application: expect.objectContaining({ + appVersion: '1.0', + environment: 3, + platform: 1, + }), + payloads: { + integrityPayloads: [], + nonDeletedItemCount: 0, + invalidPayloadsCount: 0, + }, + items: { allIds: [] }, + storage: { + storagePersistable: false, + persistencePolicy: 'Default', + encryptionPolicy: 'Default', + needsPersist: false, + currentPersistPromise: false, + isStorageWrapped: false, + allRawPayloadsCount: 0, + databaseKeys: ['1', '2', '3'], + }, + encryption: expect.objectContaining({ + getLatestVersion: '004', + hasAccount: false, + getUserVersion: undefined, + upgradeAvailable: false, + accountUpgradeAvailable: false, + passcodeUpgradeAvailable: false, + hasPasscode: false, + isPasscodeLocked: false, + itemsEncryption: expect.objectContaining({ + itemsKeysIds: [], + }), + rootKeyEncryption: expect.objectContaining({ + hasRootKey: false, + keyMode: 'RootKeyNone', + hasRootKeyWrapper: false, + hasAccount: false, + hasPasscode: false, + }), + }), + api: { + hasSession: false, + user: undefined, + registering: false, + authenticating: false, + changing: false, + refreshingSession: false, + filesHost: undefined, + host: 'localhost', + }, + session: { + isSessionRenewChallengePresented: false, + online: false, + offline: true, + isSignedIn: false, + isSignedIntoFirstPartyServer: false, + }, + sync: { + syncToken: undefined, + cursorToken: undefined, + lastSyncDate: undefined, + outOfSync: false, + completedOnlineDownloadFirstSync: false, + clientLocked: false, + databaseLoaded: false, + syncLock: false, + dealloced: false, + itemsNeedingSync: [], + itemsNeedingSyncCount: 0, + pendingRequestCount: 0, + }, + protections: expect.objectContaining({ + getLastSessionLength: undefined, + hasProtectionSources: false, + hasUnprotectedAccessSession: true, + hasBiometricsEnabled: false, + }), + keyRecovery: { queueLength: 0, isProcessingQueue: false }, + features: { + roles: [], + features: [], + enabledExperimentalFeatures: [], + needsInitialFeaturesUpdate: true, + completedSuccessfulFeaturesRetrieval: false, + }, + migrations: { activeMigrations: [] }, + }), + ) + }) +}) diff --git a/packages/snjs/lib/Application/Application.ts b/packages/snjs/lib/Application/Application.ts new file mode 100644 index 000000000..5219343a9 --- /dev/null +++ b/packages/snjs/lib/Application/Application.ts @@ -0,0 +1,1568 @@ +import { SnjsVersion } from './../Version' +import { + HttpService, + HttpServiceInterface, + UserApiService, + UserApiServiceInterface, + UserRegistrationResponseBody, + UserServer, + UserServerInterface, +} from '@standardnotes/api' +import * as Common from '@standardnotes/common' +import * as ExternalServices from '@standardnotes/services' +import * as Encryption from '@standardnotes/encryption' +import * as Models from '@standardnotes/models' +import * as Responses from '@standardnotes/responses' +import * as InternalServices from '../Services' +import * as Utils from '@standardnotes/utils' +import * as Settings from '@standardnotes/settings' +import * as Files from '@standardnotes/files' +import { Subscription } from '@standardnotes/security' +import { UuidString, ApplicationEventPayload } from '../Types' +import { ApplicationEvent, applicationEventForSyncEvent } from '@Lib/Application/Event' +import { + ChallengeValidation, + DiagnosticInfo, + Environment, + isDesktopDevice, + Platform, + ChallengeValue, + StorageKey, + ChallengeReason, + DeinitMode, + DeinitSource, + AppGroupManagedApplication, + ApplicationInterface, +} from '@standardnotes/services' +import { SNLog } from '../Log' +import { useBoolean } from '@standardnotes/utils' +import { DecryptedItemInterface, EncryptedItemInterface } from '@standardnotes/models' +import { ClientDisplayableError } from '@standardnotes/responses' +import { Challenge, ChallengeResponse } from '../Services' +import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions' +import { ApplicationOptionsDefaults } from './Options/Defaults' + +/** How often to automatically sync, in milliseconds */ +const DEFAULT_AUTO_SYNC_INTERVAL = 30_000 + +type LaunchCallback = { + receiveChallenge: (challenge: Challenge) => void +} + +type ApplicationEventCallback = (event: ApplicationEvent, data?: unknown) => Promise + +type ApplicationObserver = { + singleEvent?: ApplicationEvent + callback: ApplicationEventCallback +} + +type ItemStream = (data: { + changed: I[] + inserted: I[] + removed: (Models.DeletedItemInterface | Models.EncryptedItemInterface)[] + source: Models.PayloadEmitSource +}) => void + +type ObserverRemover = () => void + +export class SNApplication + implements ApplicationInterface, AppGroupManagedApplication, InternalServices.ListedClientInterface +{ + onDeinit!: ExternalServices.DeinitCallback + + /** + * A runtime based identifier for each dynamic instantiation of the application instance. + * This differs from the persistent application.identifier which persists in storage + * across instantiations. + */ + public readonly ephemeralIdentifier = Utils.nonSecureRandomIdentifier() + + private migrationService!: InternalServices.SNMigrationService + /** + * @deprecated will be fully replaced by @standardnotes/api::HttpService + */ + private deprecatedHttpService!: InternalServices.SNHttpService + private declare httpService: HttpServiceInterface + private payloadManager!: InternalServices.PayloadManager + public protocolService!: Encryption.EncryptionService + private diskStorageService!: InternalServices.DiskStorageService + private inMemoryStore!: ExternalServices.KeyValueStoreInterface + /** + * @deprecated will be fully replaced by @standardnotes/api services + */ + private apiService!: InternalServices.SNApiService + private declare userApiService: UserApiServiceInterface + private declare userServer: UserServerInterface + private sessionManager!: InternalServices.SNSessionManager + private syncService!: InternalServices.SNSyncService + private challengeService!: InternalServices.ChallengeService + public singletonManager!: InternalServices.SNSingletonManager + public componentManager!: InternalServices.SNComponentManager + public protectionService!: InternalServices.SNProtectionService + public actionsManager!: InternalServices.SNActionsService + public historyManager!: InternalServices.SNHistoryManager + private itemManager!: InternalServices.ItemManager + private keyRecoveryService!: InternalServices.SNKeyRecoveryService + private preferencesService!: InternalServices.SNPreferencesService + private featuresService!: InternalServices.SNFeaturesService + private userService!: InternalServices.UserService + private webSocketsService!: InternalServices.SNWebSocketsService + private settingsService!: InternalServices.SNSettingsService + private mfaService!: InternalServices.SNMfaService + private listedService!: InternalServices.ListedService + private fileService!: Files.FileService + private mutatorService!: InternalServices.MutatorService + private integrityService!: ExternalServices.IntegrityService + private statusService!: ExternalServices.StatusService + private filesBackupService?: Files.FilesBackupService + + private internalEventBus!: ExternalServices.InternalEventBusInterface + + private eventHandlers: ApplicationObserver[] = [] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private services: ExternalServices.ServiceInterface[] = [] + private streamRemovers: ObserverRemover[] = [] + private serviceObservers: ObserverRemover[] = [] + private managedSubscribers: ObserverRemover[] = [] + private autoSyncInterval!: ReturnType + + /** True if the result of deviceInterface.openDatabase yields a new database being created */ + private createdNewDatabase = false + /** True if the application has started (but not necessarily launched) */ + private started = false + /** True if the application has launched */ + private launched = false + /** Whether the application has been destroyed via .deinit() */ + public dealloced = false + private revokingSession = false + private handledFullSyncStage = false + + public readonly environment: Environment + public readonly platform: Platform + public deviceInterface: ExternalServices.DeviceInterface + public alertService: ExternalServices.AlertService + public readonly identifier: Common.ApplicationIdentifier + public readonly options: FullyResolvedApplicationOptions + + constructor(options: ApplicationConstructorOptions) { + const allOptions: FullyResolvedApplicationOptions = { + ...ApplicationOptionsDefaults, + ...options, + } + + if (!SNLog.onLog) { + throw Error('SNLog.onLog must be set.') + } + if (!SNLog.onError) { + throw Error('SNLog.onError must be set.') + } + + const requiredOptions: (keyof FullyResolvedApplicationOptions)[] = [ + 'deviceInterface', + 'environment', + 'platform', + 'crypto', + 'alertService', + 'identifier', + 'defaultHost', + 'appVersion', + ] + + for (const optionName of requiredOptions) { + if (!allOptions[optionName]) { + throw Error(`${optionName} must be supplied when creating an application.`) + } + } + + this.environment = options.environment + this.platform = options.platform + this.deviceInterface = options.deviceInterface + this.alertService = options.alertService + this.identifier = options.identifier + this.options = Object.freeze(allOptions) + + this.constructInternalEventBus() + + this.constructServices() + + this.defineInternalEventHandlers() + } + + public get files(): Files.FilesClientInterface { + return this.fileService + } + + public get features(): InternalServices.FeaturesClientInterface { + return this.featuresService + } + + public get items(): InternalServices.ItemsClientInterface { + return this.itemManager + } + + public get protections(): InternalServices.ProtectionsClientInterface { + return this.protectionService + } + + public get sync(): InternalServices.SyncClientInterface { + return this.syncService + } + + public get user(): ExternalServices.UserClientInterface { + return this.userService + } + + public get settings(): InternalServices.SNSettingsService { + return this.settingsService + } + + public get mutator(): InternalServices.MutatorClientInterface { + return this.mutatorService + } + + public get sessions(): InternalServices.SessionsClientInterface { + return this.sessionManager + } + + public get status(): ExternalServices.StatusServiceInterface { + return this.statusService + } + + public get fileBackups(): Files.FilesBackupService | undefined { + return this.filesBackupService + } + + public computePrivateWorkspaceIdentifier(userphrase: string, name: string): Promise { + return Encryption.ComputePrivateWorkspaceIdentifier(this.options.crypto, userphrase, name) + } + + /** + * The first thing consumers should call when starting their app. + * This function will load all services in their correct order. + */ + async prepareForLaunch(callback: LaunchCallback): Promise { + await this.options.crypto.initialize() + + this.setLaunchCallback(callback) + + const databaseResult = await this.deviceInterface.openDatabase(this.identifier).catch((error) => { + void this.notifyEvent(ApplicationEvent.LocalDatabaseReadError, error) + return undefined + }) + + this.createdNewDatabase = useBoolean(databaseResult?.isNewDatabase, false) + + await this.migrationService.initialize() + + await this.notifyEvent(ApplicationEvent.MigrationsLoaded) + await this.handleStage(ExternalServices.ApplicationStage.PreparingForLaunch_0) + + await this.diskStorageService.initializeFromDisk() + await this.notifyEvent(ApplicationEvent.StorageReady) + + await this.protocolService.initialize() + + await this.handleStage(ExternalServices.ApplicationStage.ReadyForLaunch_05) + + this.started = true + await this.notifyEvent(ApplicationEvent.Started) + } + + private setLaunchCallback(callback: LaunchCallback) { + this.challengeService.sendChallenge = callback.receiveChallenge + } + + /** + * Handles device authentication, unlocks application, and + * issues a callback if a device activation requires user input + * (i.e local passcode or fingerprint). + * @param awaitDatabaseLoad + * Option to await database load before marking the app as ready. + */ + public async launch(awaitDatabaseLoad = false): Promise { + this.launched = false + + const launchChallenge = this.getLaunchChallenge() + if (launchChallenge) { + const response = await this.challengeService.promptForChallengeResponse(launchChallenge) + if (!response) { + throw Error('Launch challenge was cancelled.') + } + await this.handleLaunchChallengeResponse(response) + } + + if (this.diskStorageService.isStorageWrapped()) { + try { + await this.diskStorageService.decryptStorage() + } catch (_error) { + void this.alertService.alert( + InternalServices.ErrorAlertStrings.StorageDecryptErrorBody, + InternalServices.ErrorAlertStrings.StorageDecryptErrorTitle, + ) + } + } + await this.handleStage(ExternalServices.ApplicationStage.StorageDecrypted_09) + + this.apiService.loadHost() + this.webSocketsService.loadWebSocketUrl() + this.sessionManager.initializeFromDisk() + + this.settingsService.initializeFromDisk() + + this.featuresService.initializeFromDisk() + + this.launched = true + await this.notifyEvent(ApplicationEvent.Launched) + await this.handleStage(ExternalServices.ApplicationStage.Launched_10) + + const databasePayloads = await this.syncService.getDatabasePayloads() + await this.handleStage(ExternalServices.ApplicationStage.LoadingDatabase_11) + + if (this.createdNewDatabase) { + await this.syncService.onNewDatabaseCreated() + } + /** + * We don't want to await this, as we want to begin allowing the app to function + * before local data has been loaded fully. We await only initial + * `getDatabasePayloads` to lock in on database state. + */ + const loadPromise = this.syncService.loadDatabasePayloads(databasePayloads).then(async () => { + if (this.dealloced) { + throw 'Application has been destroyed.' + } + await this.handleStage(ExternalServices.ApplicationStage.LoadedDatabase_12) + this.beginAutoSyncTimer() + await this.syncService.sync({ + mode: ExternalServices.SyncMode.DownloadFirst, + source: ExternalServices.SyncSource.External, + }) + }) + if (awaitDatabaseLoad) { + await loadPromise + } + } + + public onStart(): void { + // optional override + } + + public onLaunch(): void { + // optional override + } + + public getLaunchChallenge(): Challenge | undefined { + return this.protectionService.createLaunchChallenge() + } + + private async handleLaunchChallengeResponse(response: ChallengeResponse) { + if (response.challenge.hasPromptForValidationType(ChallengeValidation.LocalPasscode)) { + let wrappingKey = response.artifacts?.wrappingKey + if (!wrappingKey) { + const value = response.getValueForType(ChallengeValidation.LocalPasscode) + wrappingKey = await this.protocolService.computeWrappingKey(value.value as string) + } + await this.protocolService.unwrapRootKey(wrappingKey) + } + } + + private beginAutoSyncTimer() { + this.autoSyncInterval = setInterval(() => { + this.syncService.log('Syncing from autosync') + void this.sync.sync() + }, DEFAULT_AUTO_SYNC_INTERVAL) + } + + private async handleStage(stage: ExternalServices.ApplicationStage) { + for (const service of this.services) { + await service.handleApplicationStage(stage) + } + } + + /** + * @param singleEvent Whether to only listen for a particular event. + */ + public addEventObserver(callback: ApplicationEventCallback, singleEvent?: ApplicationEvent): () => void { + const observer = { callback, singleEvent } + this.eventHandlers.push(observer) + return () => { + Utils.removeFromArray(this.eventHandlers, observer) + } + } + + public addSingleEventObserver(event: ApplicationEvent, callback: ApplicationEventCallback): () => void { + // eslint-disable-next-line @typescript-eslint/require-await + const filteredCallback = async (firedEvent: ApplicationEvent) => { + if (firedEvent === event) { + void callback(event) + } + } + return this.addEventObserver(filteredCallback, event) + } + + public async getDiagnostics(): Promise { + let result: DiagnosticInfo = { + application: { + snjsVersion: SnjsVersion, + appVersion: this.options.appVersion, + environment: this.options.environment, + platform: this.options.platform, + }, + } + + for (const service of this.services) { + const diagnostics = await service.getDiagnostics() + + if (diagnostics) { + result = { + ...result, + ...diagnostics, + } + } + } + + return result + } + + private async notifyEvent(event: ApplicationEvent, data?: ApplicationEventPayload) { + if (event === ApplicationEvent.Started) { + this.onStart() + } else if (event === ApplicationEvent.Launched) { + this.onLaunch() + } + for (const observer of this.eventHandlers.slice()) { + if ((observer.singleEvent && observer.singleEvent === event) || !observer.singleEvent) { + await observer.callback(event, data || {}) + } + } + void this.migrationService.handleApplicationEvent(event) + } + + /** + * Whether the local database has completed loading local items. + */ + public isDatabaseLoaded(): boolean { + return this.syncService.isDatabaseLoaded() + } + + public getSessions(): Promise< + (Responses.HttpResponse & { data: InternalServices.RemoteSession[] }) | Responses.HttpResponse + > { + return this.sessionManager.getSessionsList() + } + + public async revokeSession(sessionId: UuidString): Promise { + if (await this.protectionService.authorizeSessionRevoking()) { + return this.sessionManager.revokeSession(sessionId) + } + return undefined + } + + /** + * Revokes all sessions except the current one. + */ + public async revokeAllOtherSessions(): Promise { + return this.sessionManager.revokeAllOtherSessions() + } + + public userCanManageSessions(): boolean { + const userVersion = this.getUserVersion() + if (Utils.isNullOrUndefined(userVersion)) { + return false + } + return Common.compareVersions(userVersion, Common.ProtocolVersion.V004) >= 0 + } + + public async getUserSubscription(): Promise { + return this.sessionManager.getSubscription() + } + + public async getAvailableSubscriptions(): Promise< + Responses.AvailableSubscriptions | Responses.ClientDisplayableError + > { + return this.sessionManager.getAvailableSubscriptions() + } + + /** + * Begin streaming items to display in the UI. The stream callback will be called + * immediately with the present items that match the constraint, and over time whenever + * items matching the constraint are added, changed, or deleted. + */ + public streamItems( + contentType: Common.ContentType | Common.ContentType[], + stream: ItemStream, + ): () => void { + const removeItemManagerObserver = this.itemManager.addObserver( + contentType, + ({ changed, inserted, removed, source }) => { + stream({ changed, inserted, removed, source }) + }, + ) + + const matches = this.itemManager.getItems(contentType) + stream({ + inserted: matches, + changed: [], + removed: [], + source: Models.PayloadEmitSource.InitialObserverRegistrationPush, + }) + + this.streamRemovers.push(removeItemManagerObserver) + + return () => { + removeItemManagerObserver() + + Utils.removeFromArray(this.streamRemovers, removeItemManagerObserver) + } + } + + /** + * Set the server's URL + */ + public async setHost(host: string): Promise { + this.httpService.setHost(host) + + await this.apiService.setHost(host) + } + + public getHost(): string | undefined { + return this.apiService.getHost() + } + + public async setCustomHost(host: string): Promise { + await this.setHost(host) + this.webSocketsService.setWebSocketUrl(undefined) + } + + public getUser(): Responses.User | undefined { + if (!this.launched) { + throw Error('Attempting to access user before application unlocked') + } + return this.sessionManager.getUser() + } + + public getUserPasswordCreationDate(): Date | undefined { + return this.protocolService.getPasswordCreatedDate() + } + + public getProtocolEncryptionDisplayName(): Promise { + return this.protocolService.getEncryptionDisplayName() + } + + public getUserVersion(): Common.ProtocolVersion | undefined { + return this.protocolService.getUserVersion() + } + + /** + * Returns true if there is an upgrade available for the account or passcode + */ + public protocolUpgradeAvailable(): Promise { + return this.protocolService.upgradeAvailable() + } + + /** + * Returns true if there is an encryption source available + */ + public isEncryptionAvailable(): boolean { + return this.hasAccount() || this.hasPasscode() + } + + public async upgradeProtocolVersion(): Promise<{ + success?: true + canceled?: true + error?: { + message: string + } + }> { + const result = await this.userService.performProtocolUpgrade() + if (result.success) { + if (this.hasAccount()) { + void this.alertService.alert(InternalServices.ProtocolUpgradeStrings.SuccessAccount) + } else { + void this.alertService.alert(InternalServices.ProtocolUpgradeStrings.SuccessPasscodeOnly) + } + } else if (result.error) { + void this.alertService.alert(InternalServices.ProtocolUpgradeStrings.Fail) + } + return result + } + + public noAccount(): boolean { + return !this.hasAccount() + } + + public hasAccount(): boolean { + return this.protocolService.hasAccount() + } + + /** + * @returns true if the user has a source of protection available, such as a + * passcode, password, or biometrics. + */ + public hasProtectionSources(): boolean { + return this.protectionService.hasProtectionSources() + } + + public hasUnprotectedAccessSession(): boolean { + return this.protectionService.hasUnprotectedAccessSession() + } + + /** + * When a user specifies a non-zero remember duration on a protection + * challenge, a session will be started during which protections are disabled. + */ + public getProtectionSessionExpiryDate(): Date { + return this.protectionService.getSessionExpiryDate() + } + + public clearProtectionSession(): Promise { + return this.protectionService.clearSession() + } + + public async authorizeProtectedActionForNotes( + notes: Models.SNNote[], + challengeReason: ChallengeReason, + ): Promise { + return await this.protectionService.authorizeProtectedActionForItems(notes, challengeReason) + } + + /** + * @returns whether note access has been granted or not + */ + public authorizeNoteAccess(note: Models.SNNote): Promise { + return this.protectionService.authorizeItemAccess(note) + } + + public authorizeAutolockIntervalChange(): Promise { + return this.protectionService.authorizeAutolockIntervalChange() + } + + public authorizeSearchingProtectedNotesText(): Promise { + return this.protectionService.authorizeSearchingProtectedNotesText() + } + + public canRegisterNewListedAccount(): boolean { + return this.listedService.canRegisterNewListedAccount() + } + + public async requestNewListedAccount(): Promise { + return this.listedService.requestNewListedAccount() + } + + public async getListedAccounts(): Promise { + return this.listedService.getListedAccounts() + } + + public getListedAccountInfo( + account: Responses.ListedAccount, + inContextOfItem?: UuidString, + ): Promise { + return this.listedService.getListedAccountInfo(account, inContextOfItem) + } + + public async createEncryptedBackupFileForAutomatedDesktopBackups(): Promise { + return this.protocolService.createEncryptedBackupFile() + } + + public async createEncryptedBackupFile(): Promise { + if (!(await this.protectionService.authorizeBackupCreation())) { + return + } + + return this.protocolService.createEncryptedBackupFile() + } + + public async createDecryptedBackupFile(): Promise { + if (!(await this.protectionService.authorizeBackupCreation())) { + return + } + + return this.protocolService.createDecryptedBackupFile() + } + + public isEphemeralSession(): boolean { + return this.diskStorageService.isEphemeralSession() + } + + public setValue(key: string, value: unknown, mode?: ExternalServices.StorageValueModes): void { + return this.diskStorageService.setValue(key, value, mode) + } + + public getValue(key: string, mode?: ExternalServices.StorageValueModes): unknown { + return this.diskStorageService.getValue(key, mode) + } + + public async removeValue(key: string, mode?: ExternalServices.StorageValueModes): Promise { + return this.diskStorageService.removeValue(key, mode) + } + + public getPreference(key: K): Models.PrefValue[K] | undefined + public getPreference(key: K, defaultValue: Models.PrefValue[K]): Models.PrefValue[K] + public getPreference( + key: K, + defaultValue?: Models.PrefValue[K], + ): Models.PrefValue[K] | undefined { + return this.preferencesService.getValue(key, defaultValue) + } + + public async setPreference(key: K, value: Models.PrefValue[K]): Promise { + return this.preferencesService.setValue(key, value) + } + + /** + * Gives services a chance to complete any sensitive operations before yielding + * @param maxWait The maximum number of milliseconds to wait for services + * to finish tasks. 0 means no limit. + */ + private async prepareForDeinit(maxWait = 0): Promise { + const promise = Promise.all(this.services.map((service) => service.blockDeinit())) + if (maxWait === 0) { + await promise + } else { + /** Await up to maxWait. If not resolved by then, return. */ + await Promise.race([promise, Utils.sleep(maxWait)]) + } + } + + public promptForCustomChallenge(challenge: Challenge): Promise { + return this.challengeService?.promptForChallengeResponse(challenge) + } + + public addChallengeObserver(challenge: Challenge, observer: InternalServices.ChallengeObserver): () => void { + return this.challengeService.addChallengeObserver(challenge, observer) + } + + public submitValuesForChallenge(challenge: Challenge, values: ChallengeValue[]): Promise { + return this.challengeService.submitValuesForChallenge(challenge, values) + } + + public cancelChallenge(challenge: Challenge): void { + this.challengeService.cancelChallenge(challenge) + } + + public setOnDeinit(onDeinit: ExternalServices.DeinitCallback): void { + this.onDeinit = onDeinit + } + + /** + * Destroys the application instance. + */ + public deinit(mode: DeinitMode, source: DeinitSource): void { + this.dealloced = true + + clearInterval(this.autoSyncInterval) + ;(this.autoSyncInterval as unknown) = undefined + + for (const uninstallObserver of this.serviceObservers) { + uninstallObserver() + } + + for (const uninstallSubscriber of this.managedSubscribers) { + uninstallSubscriber() + } + + for (const service of this.services) { + service.deinit() + } + + this.options.crypto.deinit() + ;(this.options as unknown) = undefined + + this.createdNewDatabase = false + this.services.length = 0 + this.serviceObservers.length = 0 + this.managedSubscribers.length = 0 + this.streamRemovers.length = 0 + + this.clearInternalEventBus() + this.clearServices() + + this.started = false + + this.onDeinit?.(this, mode, source) + ;(this.onDeinit as unknown) = undefined + } + + /** + * @param mergeLocal Whether to merge existing offline data into account. If false, + * any pre-existing data will be fully deleted upon success. + */ + public async register( + email: string, + password: string, + ephemeral = false, + mergeLocal = true, + ): Promise { + return this.userService.register(email, password, ephemeral, mergeLocal) + } + + /** + * @param mergeLocal Whether to merge existing offline data into account. + * If false, any pre-existing data will be fully deleted upon success. + */ + public async signIn( + email: string, + password: string, + strict = false, + ephemeral = false, + mergeLocal = true, + awaitSync = false, + ): Promise { + return this.userService.signIn(email, password, strict, ephemeral, mergeLocal, awaitSync) + } + + public async changeEmail( + newEmail: string, + currentPassword: string, + passcode?: string, + origination = Common.KeyParamsOrigination.EmailChange, + ): Promise { + return this.userService.changeCredentials({ + currentPassword, + newEmail, + passcode, + origination, + validateNewPasswordStrength: false, + }) + } + + public async changePassword( + currentPassword: string, + newPassword: string, + passcode?: string, + origination = Common.KeyParamsOrigination.PasswordChange, + validateNewPasswordStrength = true, + ): Promise { + return this.userService.changeCredentials({ + currentPassword, + newPassword, + passcode, + origination, + validateNewPasswordStrength, + }) + } + + private async handleRevokedSession(): Promise { + /** + * Because multiple API requests can come back at the same time + * indicating revoked session we only want to do this once. + */ + if (this.revokingSession) { + return + } + this.revokingSession = true + /** Keep a reference to the soon-to-be-cleared alertService */ + const alertService = this.alertService + await this.user.signOut(true) + void alertService.alert(InternalServices.SessionStrings.CurrentSessionRevoked) + } + + public async validateAccountPassword(password: string): Promise { + const { valid } = await this.protocolService.validateAccountPassword(password) + return valid + } + + public isStarted(): boolean { + return this.started + } + + public isLaunched(): boolean { + return this.launched + } + + public hasBiometrics(): boolean { + return this.protectionService.hasBiometricsEnabled() + } + + /** + * @returns whether the operation was successful or not + */ + public enableBiometrics(): boolean { + return this.protectionService.enableBiometrics() + } + + /** + * @returns whether the operation was successful or not + */ + public disableBiometrics(): Promise { + return this.protectionService.disableBiometrics() + } + + public hasPasscode(): boolean { + return this.protocolService.hasPasscode() + } + + isLocked(): Promise { + if (!this.started) { + return Promise.resolve(true) + } + return this.challengeService.isPasscodeLocked() + } + + public async lock(): Promise { + /** Because locking is a critical operation, we want to try to do it safely, + * but only up to a certain limit. */ + const MaximumWaitTime = 500 + await this.prepareForDeinit(MaximumWaitTime) + return this.deinit(this.getDeinitMode(), DeinitSource.Lock) + } + + getDeinitMode(): DeinitMode { + const value = this.getValue(StorageKey.DeinitMode) + if (value === 'hard') { + return DeinitMode.Hard + } + + return DeinitMode.Soft + } + + public addPasscode(passcode: string): Promise { + return this.userService.addPasscode(passcode) + } + + /** + * @returns whether the passcode was successfuly removed + */ + public async removePasscode(): Promise { + return this.userService.removePasscode() + } + + public async changePasscode( + newPasscode: string, + origination = Common.KeyParamsOrigination.PasscodeChange, + ): Promise { + return this.userService.changePasscode(newPasscode, origination) + } + + public getStorageEncryptionPolicy(): ExternalServices.StorageEncryptionPolicy { + return this.diskStorageService.getStorageEncryptionPolicy() + } + + public setStorageEncryptionPolicy(encryptionPolicy: ExternalServices.StorageEncryptionPolicy): Promise { + this.diskStorageService.setEncryptionPolicy(encryptionPolicy) + return this.protocolService.repersistAllItems() + } + + public enableEphemeralPersistencePolicy(): Promise { + return this.diskStorageService.setPersistencePolicy(ExternalServices.StoragePersistencePolicies.Ephemeral) + } + + public hasPendingMigrations(): Promise { + return this.migrationService.hasPendingMigrations() + } + + public generateUuid(): string { + return Utils.UuidGenerator.GenerateUuid() + } + + public presentKeyRecoveryWizard(): void { + return this.keyRecoveryService.presentKeyRecoveryWizard() + } + + public canAttemptDecryptionOfItem(item: EncryptedItemInterface): ClientDisplayableError | true { + return this.keyRecoveryService.canAttemptDecryptionOfItem(item) + } + + /** + * Dynamically change the device interface, i.e when Desktop wants to override + * default web interface. + */ + public changeDeviceInterface(deviceInterface: ExternalServices.DeviceInterface): void { + this.deviceInterface = deviceInterface + + for (const service of this.services) { + if ('deviceInterface' in service) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(service as any)['deviceInterface'] = deviceInterface + } + } + } + + public isMfaFeatureAvailable(): boolean { + return this.mfaService.isMfaFeatureAvailable() + } + + public async isMfaActivated(): Promise { + return this.mfaService.isMfaActivated() + } + + public async generateMfaSecret(): Promise { + return this.mfaService.generateMfaSecret() + } + + public async getOtpToken(secret: string): Promise { + return this.mfaService.getOtpToken(secret) + } + + public async enableMfa(secret: string, otpToken: string): Promise { + return this.mfaService.enableMfa(secret, otpToken) + } + + public async disableMfa(): Promise { + if (await this.protectionService.authorizeMfaDisable()) { + return this.mfaService.disableMfa() + } + } + + public getNewSubscriptionToken(): Promise { + return this.apiService.getNewSubscriptionToken() + } + + public isThirdPartyHostUsed(): boolean { + return this.apiService.isThirdPartyHostUsed() + } + + public getCloudProviderIntegrationUrl(cloudProviderName: Settings.CloudProvider, isDevEnvironment: boolean): string { + return this.settingsService.getCloudProviderIntegrationUrl(cloudProviderName, isDevEnvironment) + } + + private constructServices() { + this.createPayloadManager() + this.createItemManager() + this.createDiskStorageManager() + this.createInMemoryStorageManager() + this.createProtocolService() + this.diskStorageService.provideEncryptionProvider(this.protocolService) + this.createChallengeService() + this.createHttpManager() + this.createApiService() + this.createHttpService() + this.createUserServer() + this.createUserApiService() + this.createWebSocketsService() + this.createSessionManager() + this.createHistoryManager() + this.createSyncManager() + this.createProtectionService() + this.createUserService() + this.createKeyRecoveryService() + this.createSingletonManager() + this.createPreferencesService() + this.createSettingsService() + this.createFeaturesService() + this.createComponentManager() + this.createMigrationService() + this.createMfaService() + this.createListedService() + this.createActionsManager() + this.createFileService() + this.createIntegrityService() + this.createMutatorService() + this.createStatusService() + + if (isDesktopDevice(this.deviceInterface)) { + this.createFilesBackupService(this.deviceInterface) + } + } + + private clearServices() { + ;(this.migrationService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.deprecatedHttpService as unknown) = undefined + ;(this.httpService as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.diskStorageService as unknown) = undefined + ;(this.inMemoryStore as unknown) = undefined + ;(this.apiService as unknown) = undefined + ;(this.userApiService as unknown) = undefined + ;(this.userServer as unknown) = undefined + ;(this.sessionManager as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.singletonManager as unknown) = undefined + ;(this.componentManager as unknown) = undefined + ;(this.protectionService as unknown) = undefined + ;(this.actionsManager as unknown) = undefined + ;(this.historyManager as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.keyRecoveryService as unknown) = undefined + ;(this.preferencesService as unknown) = undefined + ;(this.featuresService as unknown) = undefined + ;(this.userService as unknown) = undefined + ;(this.webSocketsService as unknown) = undefined + ;(this.settingsService as unknown) = undefined + ;(this.mfaService as unknown) = undefined + ;(this.listedService as unknown) = undefined + ;(this.fileService as unknown) = undefined + ;(this.integrityService as unknown) = undefined + ;(this.mutatorService as unknown) = undefined + ;(this.filesBackupService as unknown) = undefined + ;(this.statusService as unknown) = undefined + + this.services = [] + } + + private constructInternalEventBus(): void { + this.internalEventBus = new ExternalServices.InternalEventBus() + } + + private defineInternalEventHandlers(): void { + this.internalEventBus.addEventHandler(this.featuresService, ExternalServices.ApiServiceEvent.MetaReceived) + this.internalEventBus.addEventHandler(this.integrityService, ExternalServices.SyncEvent.SyncRequestsIntegrityCheck) + this.internalEventBus.addEventHandler(this.syncService, ExternalServices.IntegrityEvent.IntegrityCheckCompleted) + } + + private clearInternalEventBus(): void { + this.internalEventBus.deinit() + ;(this.internalEventBus as unknown) = undefined + } + + private createListedService(): void { + this.listedService = new InternalServices.ListedService( + this.apiService, + this.itemManager, + this.settingsService, + this.deprecatedHttpService, + this.internalEventBus, + ) + this.services.push(this.listedService) + } + + private createFileService() { + this.fileService = new Files.FileService( + this.apiService, + this.itemManager, + this.syncService, + this.protocolService, + this.challengeService, + this.alertService, + this.options.crypto, + this.internalEventBus, + ) + + this.services.push(this.fileService) + } + + private createIntegrityService() { + this.integrityService = new ExternalServices.IntegrityService( + this.apiService, + this.apiService, + this.payloadManager, + this.internalEventBus, + ) + + this.services.push(this.integrityService) + } + + private createFeaturesService() { + this.featuresService = new InternalServices.SNFeaturesService( + this.diskStorageService, + this.apiService, + this.itemManager, + this.webSocketsService, + this.settingsService, + this.userService, + this.syncService, + this.alertService, + this.sessionManager, + this.options.crypto, + this.internalEventBus, + ) + this.serviceObservers.push( + this.featuresService.addEventObserver((event) => { + switch (event) { + case InternalServices.FeaturesEvent.UserRolesChanged: { + void this.notifyEvent(ApplicationEvent.UserRolesChanged) + break + } + case InternalServices.FeaturesEvent.FeaturesUpdated: { + void this.notifyEvent(ApplicationEvent.FeaturesUpdated) + break + } + default: { + Utils.assertUnreachable(event) + } + } + }), + ) + this.services.push(this.featuresService) + } + + private createWebSocketsService() { + this.webSocketsService = new InternalServices.SNWebSocketsService( + this.diskStorageService, + this.options.webSocketUrl, + this.internalEventBus, + ) + this.services.push(this.webSocketsService) + } + + private createMigrationService() { + this.migrationService = new InternalServices.SNMigrationService({ + protocolService: this.protocolService, + deviceInterface: this.deviceInterface, + storageService: this.diskStorageService, + sessionManager: this.sessionManager, + challengeService: this.challengeService, + itemManager: this.itemManager, + singletonManager: this.singletonManager, + featuresService: this.featuresService, + environment: this.environment, + identifier: this.identifier, + internalEventBus: this.internalEventBus, + }) + this.services.push(this.migrationService) + } + + private createUserService(): void { + this.userService = new InternalServices.UserService( + this.sessionManager, + this.syncService, + this.diskStorageService, + this.itemManager, + this.protocolService, + this.alertService, + this.challengeService, + this.protectionService, + this.apiService, + this.internalEventBus, + ) + this.serviceObservers.push( + this.userService.addEventObserver(async (event, data) => { + switch (event) { + case InternalServices.AccountEvent.SignedInOrRegistered: { + void this.notifyEvent(ApplicationEvent.SignedIn) + break + } + case InternalServices.AccountEvent.SignedOut: { + await this.notifyEvent(ApplicationEvent.SignedOut) + await this.prepareForDeinit() + this.deinit(this.getDeinitMode(), data?.source || DeinitSource.SignOut) + break + } + default: { + Utils.assertUnreachable(event) + } + } + }), + ) + this.services.push(this.userService) + } + + private createApiService() { + this.apiService = new InternalServices.SNApiService( + this.deprecatedHttpService, + this.diskStorageService, + this.options.defaultHost, + this.inMemoryStore, + this.options.crypto, + this.internalEventBus, + ) + this.services.push(this.apiService) + } + + private createUserApiService() { + this.userApiService = new UserApiService(this.userServer) + } + + private createUserServer() { + this.userServer = new UserServer(this.httpService) + } + + private createItemManager() { + this.itemManager = new InternalServices.ItemManager(this.payloadManager, this.options, this.internalEventBus) + this.services.push(this.itemManager) + } + + private createComponentManager() { + const MaybeSwappedComponentManager = this.getClass( + InternalServices.SNComponentManager, + ) + this.componentManager = new MaybeSwappedComponentManager( + this.itemManager, + this.syncService, + this.featuresService, + this.preferencesService, + this.alertService, + this.environment, + this.platform, + this.internalEventBus, + ) + this.services.push(this.componentManager) + } + + private createHttpManager() { + this.deprecatedHttpService = new InternalServices.SNHttpService( + this.environment, + this.options.appVersion, + this.internalEventBus, + ) + this.services.push(this.deprecatedHttpService) + } + + private createHttpService() { + this.httpService = new HttpService( + this.environment, + this.options.appVersion, + SnjsVersion, + this.options.defaultHost, + this.apiService.processMetaObject.bind(this.apiService), + ) + } + + private createPayloadManager() { + this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus) + this.services.push(this.payloadManager) + } + + private createSingletonManager() { + this.singletonManager = new InternalServices.SNSingletonManager( + this.itemManager, + this.payloadManager, + this.syncService, + this.internalEventBus, + ) + this.services.push(this.singletonManager) + } + + private createDiskStorageManager() { + this.diskStorageService = new InternalServices.DiskStorageService( + this.deviceInterface, + this.identifier, + this.environment, + this.internalEventBus, + ) + this.services.push(this.diskStorageService) + } + + private createInMemoryStorageManager() { + this.inMemoryStore = new ExternalServices.InMemoryStore() + } + + private createProtocolService() { + this.protocolService = new Encryption.EncryptionService( + this.itemManager, + this.payloadManager, + this.deviceInterface, + this.diskStorageService, + this.identifier, + this.options.crypto, + this.internalEventBus, + ) + this.serviceObservers.push( + this.protocolService.addEventObserver(async (event) => { + if (event === Encryption.EncryptionServiceEvent.RootKeyStatusChanged) { + await this.notifyEvent(ApplicationEvent.KeyStatusChanged) + } + }), + ) + this.services.push(this.protocolService) + } + + private createKeyRecoveryService() { + this.keyRecoveryService = new InternalServices.SNKeyRecoveryService( + this.itemManager, + this.payloadManager, + this.apiService, + this.protocolService, + this.challengeService, + this.alertService, + this.diskStorageService, + this.syncService, + this.userService, + this.internalEventBus, + ) + this.services.push(this.keyRecoveryService) + } + + private createSessionManager() { + this.sessionManager = new InternalServices.SNSessionManager( + this.diskStorageService, + this.apiService, + this.userApiService, + this.alertService, + this.protocolService, + this.challengeService, + this.webSocketsService, + this.internalEventBus, + ) + this.serviceObservers.push( + this.sessionManager.addEventObserver(async (event) => { + switch (event) { + case InternalServices.SessionEvent.Restored: { + void (async () => { + await this.sync.sync() + if (this.protocolService.needsNewRootKeyBasedItemsKey()) { + void this.protocolService.createNewDefaultItemsKey().then(() => { + void this.sync.sync() + }) + } + })() + break + } + case InternalServices.SessionEvent.Revoked: { + await this.handleRevokedSession() + break + } + default: { + Utils.assertUnreachable(event) + } + } + }), + ) + this.services.push(this.sessionManager) + } + + private createSyncManager() { + this.syncService = new InternalServices.SNSyncService( + this.itemManager, + this.sessionManager, + this.protocolService, + this.diskStorageService, + this.payloadManager, + this.apiService, + this.historyManager, + { + loadBatchSize: this.options.loadBatchSize, + }, + this.internalEventBus, + ) + const syncEventCallback = async (eventName: ExternalServices.SyncEvent) => { + const appEvent = applicationEventForSyncEvent(eventName) + if (appEvent) { + await this.notifyEvent(appEvent) + if (appEvent === ApplicationEvent.CompletedFullSync) { + if (!this.handledFullSyncStage) { + this.handledFullSyncStage = true + await this.handleStage(ExternalServices.ApplicationStage.FullSyncCompleted_13) + } + } + } + await this.protocolService.onSyncEvent(eventName) + } + const uninstall = this.syncService.addEventObserver(syncEventCallback) + this.serviceObservers.push(uninstall) + this.services.push(this.syncService) + } + + private createChallengeService() { + this.challengeService = new InternalServices.ChallengeService( + this.diskStorageService, + this.protocolService, + this.internalEventBus, + ) + this.services.push(this.challengeService) + } + + private createProtectionService() { + this.protectionService = new InternalServices.SNProtectionService( + this.protocolService, + this.challengeService, + this.diskStorageService, + this.internalEventBus, + ) + this.serviceObservers.push( + this.protectionService.addEventObserver((event) => { + if (event === InternalServices.ProtectionEvent.UnprotectedSessionBegan) { + void this.notifyEvent(ApplicationEvent.UnprotectedSessionBegan) + } else if (event === InternalServices.ProtectionEvent.UnprotectedSessionExpired) { + void this.notifyEvent(ApplicationEvent.UnprotectedSessionExpired) + } + }), + ) + this.services.push(this.protectionService) + } + + private createHistoryManager() { + this.historyManager = new InternalServices.SNHistoryManager( + this.itemManager, + this.diskStorageService, + this.apiService, + this.protocolService, + this.deviceInterface, + this.internalEventBus, + ) + this.services.push(this.historyManager) + } + + private createActionsManager() { + this.actionsManager = new InternalServices.SNActionsService( + this.itemManager, + this.alertService, + this.deviceInterface, + this.deprecatedHttpService, + this.payloadManager, + this.protocolService, + this.syncService, + this.challengeService, + this.listedService, + this.internalEventBus, + ) + this.services.push(this.actionsManager) + } + + private createPreferencesService() { + this.preferencesService = new InternalServices.SNPreferencesService( + this.singletonManager, + this.itemManager, + this.syncService, + this.internalEventBus, + ) + this.serviceObservers.push( + this.preferencesService.addEventObserver(() => { + void this.notifyEvent(ApplicationEvent.PreferencesChanged) + }), + ) + this.services.push(this.preferencesService) + } + + private createSettingsService() { + this.settingsService = new InternalServices.SNSettingsService( + this.sessionManager, + this.apiService, + this.internalEventBus, + ) + this.services.push(this.settingsService) + } + + private createMfaService() { + this.mfaService = new InternalServices.SNMfaService( + this.settingsService, + this.options.crypto, + this.featuresService, + this.internalEventBus, + ) + this.services.push(this.mfaService) + } + + private createMutatorService() { + this.mutatorService = new InternalServices.MutatorService( + this.itemManager, + this.syncService, + this.protectionService, + this.protocolService, + this.payloadManager, + this.challengeService, + this.componentManager, + this.historyManager, + this.internalEventBus, + ) + this.services.push(this.mutatorService) + } + + private createFilesBackupService(device: ExternalServices.DesktopDeviceInterface): void { + this.filesBackupService = new Files.FilesBackupService( + this.itemManager, + this.apiService, + this.protocolService, + device, + this.statusService, + this.internalEventBus, + ) + this.services.push(this.filesBackupService) + } + + private createStatusService(): void { + this.statusService = new ExternalServices.StatusService(this.internalEventBus) + this.services.push(this.statusService) + } + + private getClass(base: T) { + const swapClass = this.options.swapClasses?.find((candidate) => candidate.swap === base) + if (swapClass) { + return swapClass.with as T + } else { + return base + } + } +} diff --git a/packages/snjs/lib/Application/Event.ts b/packages/snjs/lib/Application/Event.ts new file mode 100644 index 000000000..51ad3da19 --- /dev/null +++ b/packages/snjs/lib/Application/Event.ts @@ -0,0 +1,90 @@ +import { SyncEvent } from '@standardnotes/services' +export { SyncEvent } + +export enum ApplicationEvent { + SignedIn = 2, + SignedOut = 3, + + /** When a full, potentially multi-page sync completes */ + CompletedFullSync = 5, + + FailedSync = 6, + HighLatencySync = 7, + EnteredOutOfSync = 8, + ExitedOutOfSync = 9, + + /** + * The application has finished it `prepareForLaunch` state and is now ready for unlock + * Called when the application has initialized and is ready for launch, but before + * the application has been unlocked, if applicable. Use this to do pre-launch + * configuration, but do not attempt to access user data like notes or tags. + */ + Started = 10, + + /** + * The applicaiton is fully unlocked and ready for i/o + * Called when the application has been fully decrypted and unlocked. Use this to + * to begin streaming data like notes and tags. + */ + Launched = 11, + LocalDataLoaded = 12, + + /** + * When the root key or root key wrapper changes. Includes events like account state + * changes (registering, signing in, changing pw, logging out) and passcode state + * changes (adding, removing, changing). + */ + KeyStatusChanged = 13, + + MajorDataChange = 14, + CompletedRestart = 15, + LocalDataIncrementalLoad = 16, + SyncStatusChanged = 17, + WillSync = 18, + InvalidSyncSession = 19, + LocalDatabaseReadError = 20, + LocalDatabaseWriteError = 21, + + /** When a single roundtrip completes with sync, in a potentially multi-page sync request. + * If just a single roundtrip, this event will be triggered, along with CompletedFullSync */ + CompletedIncrementalSync = 22, + + /** + * The application has loaded all pending migrations (but not run any, except for the base one), + * and consumers may now call `hasPendingMigrations` + */ + MigrationsLoaded = 23, + + /** When StorageService is ready to start servicing read/write requests */ + StorageReady = 24, + + PreferencesChanged = 25, + UnprotectedSessionBegan = 26, + UserRolesChanged = 27, + FeaturesUpdated = 28, + UnprotectedSessionExpired = 29, + /** Called when the app first launches and after first sync request made after sign in */ + CompletedInitialSync = 30, +} + +export function applicationEventForSyncEvent(syncEvent: SyncEvent) { + return ( + { + [SyncEvent.SyncCompletedWithAllItemsUploaded]: ApplicationEvent.CompletedFullSync, + [SyncEvent.SingleRoundTripSyncCompleted]: ApplicationEvent.CompletedIncrementalSync, + [SyncEvent.SyncError]: ApplicationEvent.FailedSync, + [SyncEvent.SyncTakingTooLong]: ApplicationEvent.HighLatencySync, + [SyncEvent.EnterOutOfSync]: ApplicationEvent.EnteredOutOfSync, + [SyncEvent.ExitOutOfSync]: ApplicationEvent.ExitedOutOfSync, + [SyncEvent.LocalDataLoaded]: ApplicationEvent.LocalDataLoaded, + [SyncEvent.MajorDataChange]: ApplicationEvent.MajorDataChange, + [SyncEvent.LocalDataIncrementalLoad]: ApplicationEvent.LocalDataIncrementalLoad, + [SyncEvent.StatusChanged]: ApplicationEvent.SyncStatusChanged, + [SyncEvent.SyncWillBegin]: ApplicationEvent.WillSync, + [SyncEvent.InvalidSession]: ApplicationEvent.InvalidSyncSession, + [SyncEvent.DatabaseReadError]: ApplicationEvent.LocalDatabaseReadError, + [SyncEvent.DatabaseWriteError]: ApplicationEvent.LocalDatabaseWriteError, + [SyncEvent.DownloadFirstSyncCompleted]: ApplicationEvent.CompletedInitialSync, + } as any + )[syncEvent] +} diff --git a/packages/snjs/lib/Application/LiveItem.ts b/packages/snjs/lib/Application/LiveItem.ts new file mode 100644 index 000000000..0b84be86b --- /dev/null +++ b/packages/snjs/lib/Application/LiveItem.ts @@ -0,0 +1,34 @@ +import { DecryptedItemInterface } from '@standardnotes/models' +import { SNApplication } from './Application' + +/** Keeps an item reference up to date with changes */ +export class LiveItem { + public item: T + private removeObserver: () => void + + constructor(uuid: string, application: SNApplication, onChange?: (item: T) => void) { + this.item = application.items.findSureItem(uuid) + + onChange && onChange(this.item) + + this.removeObserver = application.streamItems(this.item.content_type, ({ changed, inserted }) => { + const matchingItem = [...changed, ...inserted].find((item) => { + return item.uuid === uuid + }) + + if (matchingItem) { + this.item = matchingItem as T + onChange && onChange(this.item) + } + }) + } + + public deinit() { + if (!this.removeObserver) { + console.error('A LiveItem is attempting to be deinited more than once.') + } else { + this.removeObserver() + ;(this.removeObserver as unknown) = undefined + } + } +} diff --git a/packages/snjs/lib/Application/Options/ApplicationOptions.ts b/packages/snjs/lib/Application/Options/ApplicationOptions.ts new file mode 100644 index 000000000..d7e12354d --- /dev/null +++ b/packages/snjs/lib/Application/Options/ApplicationOptions.ts @@ -0,0 +1,16 @@ +import { ApplicationOptionsWhichHaveDefaults } from './Defaults' +import { + ApplicationDisplayOptions, + ApplicationOptionalConfiguratioOptions, + ApplicationSyncOptions, +} from './OptionalOptions' +import { RequiredApplicationOptions } from './RequiredOptions' + +export type ApplicationConstructorOptions = RequiredApplicationOptions & + Partial + +export type FullyResolvedApplicationOptions = RequiredApplicationOptions & + ApplicationSyncOptions & + ApplicationDisplayOptions & + ApplicationOptionalConfiguratioOptions & + ApplicationOptionsWhichHaveDefaults diff --git a/packages/snjs/lib/Application/Options/Defaults.ts b/packages/snjs/lib/Application/Options/Defaults.ts new file mode 100644 index 000000000..a1f3adc6d --- /dev/null +++ b/packages/snjs/lib/Application/Options/Defaults.ts @@ -0,0 +1,11 @@ +import { ApplicationDisplayOptions, ApplicationSyncOptions } from './OptionalOptions' + +export interface ApplicationOptionsWhichHaveDefaults { + loadBatchSize: ApplicationSyncOptions['loadBatchSize'] + supportsFileNavigation: ApplicationDisplayOptions['supportsFileNavigation'] +} + +export const ApplicationOptionsDefaults: ApplicationOptionsWhichHaveDefaults = { + loadBatchSize: 700, + supportsFileNavigation: false, +} diff --git a/packages/snjs/lib/Application/Options/OptionalOptions.ts b/packages/snjs/lib/Application/Options/OptionalOptions.ts new file mode 100644 index 000000000..b1d1df9a8 --- /dev/null +++ b/packages/snjs/lib/Application/Options/OptionalOptions.ts @@ -0,0 +1,25 @@ +export interface ApplicationSyncOptions { + /** + * The size of the item batch to decrypt and render upon application load. + */ + loadBatchSize: number +} + +export interface ApplicationDisplayOptions { + supportsFileNavigation: boolean +} + +export interface ApplicationOptionalConfiguratioOptions { + /** + * Gives consumers the ability to provide their own custom + * subclass for a service. swapClasses should be an array of key/value pairs + * consisting of keys 'swap' and 'with'. 'swap' is the base class you wish to replace, + * and 'with' is the custom subclass to use. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + swapClasses?: { swap: any; with: any }[] + /** + * URL for WebSocket providing permissions and roles information. + */ + webSocketUrl?: string +} diff --git a/packages/snjs/lib/Application/Options/RequiredOptions.ts b/packages/snjs/lib/Application/Options/RequiredOptions.ts new file mode 100644 index 000000000..7527b4817 --- /dev/null +++ b/packages/snjs/lib/Application/Options/RequiredOptions.ts @@ -0,0 +1,42 @@ +import { ApplicationIdentifier } from '@standardnotes/common' +import { AlertService, DeviceInterface, Environment, Platform } from '@standardnotes/services' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' + +export interface RequiredApplicationOptions { + /** + * The Environment that identifies your application. + */ + environment: Environment + /** + * The Platform that identifies your application. + */ + platform: Platform + /** + * The device interface that provides platform specific + * utilities that are used to read/write raw values from/to the database or value storage. + */ + deviceInterface: DeviceInterface + /** + * The platform-dependent implementation of SNPureCrypto to use. + * Web uses SNWebCrypto, mobile uses SNReactNativeCrypto. + */ + crypto: PureCryptoInterface + /** + * The platform-dependent implementation of alert service. + */ + alertService: AlertService + /** + * A unique persistent identifier to namespace storage and other + * persistent properties. For an ephemeral runtime identifier, use ephemeralIdentifier. + */ + identifier: ApplicationIdentifier + + /** + * Default host to use in ApiService. + */ + defaultHost: string + /** + * Version of client application. + */ + appVersion: string +} diff --git a/packages/snjs/lib/Application/Platforms.ts b/packages/snjs/lib/Application/Platforms.ts new file mode 100644 index 000000000..ef4394334 --- /dev/null +++ b/packages/snjs/lib/Application/Platforms.ts @@ -0,0 +1,55 @@ +import { Environment, Platform } from '@standardnotes/services' + +export function platformFromString(string: string) { + const map: Record = { + 'mac-web': Platform.MacWeb, + 'mac-desktop': Platform.MacDesktop, + 'linux-web': Platform.LinuxWeb, + 'linux-desktop': Platform.LinuxDesktop, + 'windows-web': Platform.WindowsWeb, + 'windows-desktop': Platform.WindowsDesktop, + ios: Platform.Ios, + android: Platform.Android, + } + return map[string] +} + +export function platformToString(platform: Platform) { + const map = { + [Platform.MacWeb]: 'mac-web', + [Platform.MacDesktop]: 'mac-desktop', + [Platform.LinuxWeb]: 'linux-web', + [Platform.LinuxDesktop]: 'linux-desktop', + [Platform.WindowsWeb]: 'windows-web', + [Platform.WindowsDesktop]: 'windows-desktop', + [Platform.Ios]: 'ios', + [Platform.Android]: 'android', + } + return map[platform] +} + +export function environmentFromString(string: string) { + const map: Record = { + web: Environment.Web, + desktop: Environment.Desktop, + mobile: Environment.Mobile, + } + return map[string] +} + +export function environmentToString(environment: Environment) { + const map = { + [Environment.Web]: 'web', + [Environment.Desktop]: 'desktop', + [Environment.Mobile]: 'mobile', + } + return map[environment] +} + +export function isEnvironmentWebOrDesktop(environment: Environment) { + return environment === Environment.Web || environment === Environment.Desktop +} + +export function isEnvironmentMobile(environment: Environment) { + return environment === Environment.Mobile +} diff --git a/packages/snjs/lib/Application/index.ts b/packages/snjs/lib/Application/index.ts new file mode 100644 index 000000000..29e6e3594 --- /dev/null +++ b/packages/snjs/lib/Application/index.ts @@ -0,0 +1,4 @@ +export * from './Application' +export * from './Event' +export * from './LiveItem' +export * from './Platforms' diff --git a/packages/snjs/lib/ApplicationGroup/AppGroupCallback.ts b/packages/snjs/lib/ApplicationGroup/AppGroupCallback.ts new file mode 100644 index 000000000..080d75dde --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/AppGroupCallback.ts @@ -0,0 +1,6 @@ +import { AppGroupManagedApplication, DeviceInterface } from '@standardnotes/services' +import { ApplicationDescriptor } from './ApplicationDescriptor' + +export type AppGroupCallback = { + applicationCreator: (descriptor: ApplicationDescriptor, deviceInterface: D) => Promise +} diff --git a/packages/snjs/lib/ApplicationGroup/ApplicationDescriptor.ts b/packages/snjs/lib/ApplicationGroup/ApplicationDescriptor.ts new file mode 100644 index 000000000..a55b1c8a3 --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/ApplicationDescriptor.ts @@ -0,0 +1,7 @@ +import { ApplicationIdentifier } from '@standardnotes/common' + +export type ApplicationDescriptor = { + identifier: ApplicationIdentifier + label: string + primary: boolean +} diff --git a/packages/snjs/lib/ApplicationGroup/ApplicationGroup.ts b/packages/snjs/lib/ApplicationGroup/ApplicationGroup.ts new file mode 100644 index 000000000..05ca51e6c --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/ApplicationGroup.ts @@ -0,0 +1,247 @@ +import { + AbstractService, + AppGroupManagedApplication, + DeinitSource, + DeinitCallback, + DeviceInterface, + DeinitMode, + InternalEventBus, + InternalEventBusInterface, + RawStorageKey, +} from '@standardnotes/services' +import { UuidGenerator } from '@standardnotes/utils' +import { AppGroupCallback } from './AppGroupCallback' +import { ApplicationGroupEvent, ApplicationGroupEventData } from './ApplicationGroupEvent' +import { DescriptorRecord } from './DescriptorRecord' +import { ApplicationDescriptor } from './ApplicationDescriptor' + +export class SNApplicationGroup extends AbstractService< + ApplicationGroupEvent, + | ApplicationGroupEventData[ApplicationGroupEvent.PrimaryApplicationSet] + | ApplicationGroupEventData[ApplicationGroupEvent.DeviceWillRestart] + | ApplicationGroupEventData[ApplicationGroupEvent.DescriptorsDataChanged] +> { + public primaryApplication!: AppGroupManagedApplication + private descriptorRecord!: DescriptorRecord + callback!: AppGroupCallback + + constructor(public device: D, internalEventBus?: InternalEventBusInterface) { + if (internalEventBus === undefined) { + internalEventBus = new InternalEventBus() + } + + super(internalEventBus) + } + + override deinit() { + super.deinit() + + this.device.deinit() + ;(this.device as unknown) = undefined + ;(this.callback as unknown) = undefined + ;(this.primaryApplication as unknown) = undefined + ;(this.onApplicationDeinit as unknown) = undefined + } + + public async initialize(callback: AppGroupCallback): Promise { + if (this.device.isDeviceDestroyed()) { + throw 'Attempting to initialize new application while device is destroyed.' + } + + this.callback = callback + + this.descriptorRecord = (await this.device.getJsonParsedRawStorageValue( + RawStorageKey.DescriptorRecord, + )) as DescriptorRecord + + if (!this.descriptorRecord) { + await this.createNewDescriptorRecord() + } + + let primaryDescriptor = this.findPrimaryDescriptor() + if (!primaryDescriptor) { + console.error('No primary application descriptor found. Ensure migrations have been run.') + primaryDescriptor = this.getDescriptors()[0] + + this.setDescriptorAsPrimary(primaryDescriptor) + + await this.persistDescriptors() + } + + const application = await this.buildApplication(primaryDescriptor) + + this.primaryApplication = application + + await this.notifyEvent(ApplicationGroupEvent.PrimaryApplicationSet, { application: application }) + } + + private async createNewDescriptorRecord() { + /** + * The identifier 'standardnotes' is used because this was the + * database name of Standard Notes web/desktop + * */ + const identifier = 'standardnotes' + const descriptorRecord: DescriptorRecord = { + [identifier]: { + identifier: identifier, + label: 'Main Workspace', + primary: true, + }, + } + + void this.device.setRawStorageValue(RawStorageKey.DescriptorRecord, JSON.stringify(descriptorRecord)) + + this.descriptorRecord = descriptorRecord + + await this.persistDescriptors() + } + + public getDescriptors() { + return Object.values(this.descriptorRecord) + } + + private findPrimaryDescriptor() { + for (const descriptor of this.getDescriptors()) { + if (descriptor.primary) { + return descriptor + } + } + return undefined + } + + async signOutAllWorkspaces() { + await this.primaryApplication.user.signOut(false, DeinitSource.SignOutAll) + } + + onApplicationDeinit: DeinitCallback = ( + application: AppGroupManagedApplication, + mode: DeinitMode, + source: DeinitSource, + ) => { + if (this.primaryApplication === application) { + ;(this.primaryApplication as unknown) = undefined + } + + const performSyncronously = async () => { + if (source === DeinitSource.SignOut) { + void this.removeDescriptor(this.descriptorForApplication(application)) + } + + const descriptors = this.getDescriptors() + + if (descriptors.length === 0 || source === DeinitSource.SignOutAll) { + const identifiers = descriptors.map((d) => d.identifier) + + this.descriptorRecord = {} + + const { killsApplication } = await this.device.clearAllDataFromDevice(identifiers) + + if (killsApplication) { + return + } + } + + const device = this.device + + void this.notifyEvent(ApplicationGroupEvent.DeviceWillRestart, { source, mode }) + + this.deinit() + + if (mode === DeinitMode.Hard) { + device.performHardReset() + } else { + device.performSoftReset() + } + } + + void performSyncronously() + } + + public setDescriptorAsPrimary(primaryDescriptor: ApplicationDescriptor) { + for (const descriptor of this.getDescriptors()) { + descriptor.primary = descriptor === primaryDescriptor + } + } + + private async persistDescriptors() { + await this.device.setRawStorageValue(RawStorageKey.DescriptorRecord, JSON.stringify(this.descriptorRecord)) + + void this.notifyEvent(ApplicationGroupEvent.DescriptorsDataChanged, { descriptors: this.descriptorRecord }) + } + + public renameDescriptor(descriptor: ApplicationDescriptor, label: string) { + descriptor.label = label + + void this.persistDescriptors() + } + + public removeDescriptor(descriptor: ApplicationDescriptor) { + delete this.descriptorRecord[descriptor.identifier] + + const descriptors = this.getDescriptors() + if (descriptor.primary && descriptors.length > 0) { + this.setDescriptorAsPrimary(descriptors[0]) + } + + return this.persistDescriptors() + } + + public removeAllDescriptors() { + this.descriptorRecord = {} + + return this.persistDescriptors() + } + + private descriptorForApplication(application: AppGroupManagedApplication) { + return this.descriptorRecord[application.identifier] + } + + private createNewApplicationDescriptor(label?: string) { + const identifier = UuidGenerator.GenerateUuid() + const index = this.getDescriptors().length + 1 + + const descriptor: ApplicationDescriptor = { + identifier: identifier, + label: label || `Workspace ${index}`, + primary: false, + } + + return descriptor + } + + private async createNewPrimaryDescriptor(label?: string): Promise { + const descriptor = this.createNewApplicationDescriptor(label) + + this.descriptorRecord[descriptor.identifier] = descriptor + + this.setDescriptorAsPrimary(descriptor) + + await this.persistDescriptors() + } + + public async unloadCurrentAndCreateNewDescriptor(label?: string): Promise { + await this.createNewPrimaryDescriptor(label) + + if (this.primaryApplication) { + this.primaryApplication.deinit(this.primaryApplication.getDeinitMode(), DeinitSource.SwitchWorkspace) + } + } + + public async unloadCurrentAndActivateDescriptor(descriptor: ApplicationDescriptor) { + this.setDescriptorAsPrimary(descriptor) + + await this.persistDescriptors() + + if (this.primaryApplication) { + this.primaryApplication.deinit(this.primaryApplication.getDeinitMode(), DeinitSource.SwitchWorkspace) + } + } + + private async buildApplication(descriptor: ApplicationDescriptor) { + const application = await this.callback.applicationCreator(descriptor, this.device) + + application.setOnDeinit(this.onApplicationDeinit) + + return application + } +} diff --git a/packages/snjs/lib/ApplicationGroup/ApplicationGroupEvent.ts b/packages/snjs/lib/ApplicationGroup/ApplicationGroupEvent.ts new file mode 100644 index 000000000..302b77efa --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/ApplicationGroupEvent.ts @@ -0,0 +1,21 @@ +import { ApplicationInterface, DeinitMode, DeinitSource } from '@standardnotes/services' +import { DescriptorRecord } from './DescriptorRecord' + +export enum ApplicationGroupEvent { + PrimaryApplicationSet = 'PrimaryApplicationSet', + DescriptorsDataChanged = 'DescriptorsDataChanged', + DeviceWillRestart = 'DeviceWillRestart', +} + +export interface ApplicationGroupEventData { + [ApplicationGroupEvent.PrimaryApplicationSet]: { + application: ApplicationInterface + } + [ApplicationGroupEvent.DeviceWillRestart]: { + source: DeinitSource + mode: DeinitMode + } + [ApplicationGroupEvent.DescriptorsDataChanged]: { + descriptors: DescriptorRecord + } +} diff --git a/packages/snjs/lib/ApplicationGroup/DeinitCallback.ts b/packages/snjs/lib/ApplicationGroup/DeinitCallback.ts new file mode 100644 index 000000000..b76798ff1 --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/DeinitCallback.ts @@ -0,0 +1,3 @@ +import { AppGroupManagedApplication, DeinitSource, DeinitMode } from '@standardnotes/services' + +export type DeinitCallback = (application: AppGroupManagedApplication, mode: DeinitMode, source: DeinitSource) => void diff --git a/packages/snjs/lib/ApplicationGroup/DescriptorRecord.ts b/packages/snjs/lib/ApplicationGroup/DescriptorRecord.ts new file mode 100644 index 000000000..31171dd56 --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/DescriptorRecord.ts @@ -0,0 +1,3 @@ +import { ApplicationDescriptor } from './ApplicationDescriptor' + +export type DescriptorRecord = Record diff --git a/packages/snjs/lib/ApplicationGroup/index.ts b/packages/snjs/lib/ApplicationGroup/index.ts new file mode 100644 index 000000000..6a5f7e15f --- /dev/null +++ b/packages/snjs/lib/ApplicationGroup/index.ts @@ -0,0 +1,5 @@ +export * from './AppGroupCallback' +export * from './ApplicationDescriptor' +export * from './ApplicationGroup' +export * from './ApplicationGroupEvent' +export * from './DescriptorRecord' diff --git a/packages/snjs/lib/Client/FileViewController.ts b/packages/snjs/lib/Client/FileViewController.ts new file mode 100644 index 000000000..302ff55be --- /dev/null +++ b/packages/snjs/lib/Client/FileViewController.ts @@ -0,0 +1,41 @@ +import { FileItem } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { SNApplication } from '../Application/Application' +import { ItemViewControllerInterface } from './ItemViewControllerInterface' + +export class FileViewController implements ItemViewControllerInterface { + public dealloced = false + private removeStreamObserver?: () => void + + constructor(private application: SNApplication, public item: FileItem) {} + + deinit() { + this.dealloced = true + this.removeStreamObserver?.() + ;(this.removeStreamObserver as unknown) = undefined + ;(this.application as unknown) = undefined + ;(this.item as unknown) = undefined + } + + async initialize() { + this.streamItems() + } + + private streamItems() { + this.removeStreamObserver = this.application.streamItems(ContentType.File, ({ changed, inserted }) => { + if (this.dealloced) { + return + } + + const files = changed.concat(inserted) + + const matchingFile = files.find((item) => { + return item.uuid === this.item.uuid + }) + + if (matchingFile) { + this.item = matchingFile + } + }) + } +} diff --git a/packages/snjs/lib/Client/IconsController.spec.ts b/packages/snjs/lib/Client/IconsController.spec.ts new file mode 100644 index 000000000..5250489a8 --- /dev/null +++ b/packages/snjs/lib/Client/IconsController.spec.ts @@ -0,0 +1,69 @@ +import { IconsController } from './IconsController' + +describe('IconsController', () => { + let iconsController: IconsController + + beforeEach(() => { + iconsController = new IconsController() + }) + + describe('getIconForFileType', () => { + it('should return correct icon type for supported mimetypes', () => { + const iconTypeForPdf = iconsController.getIconForFileType('application/pdf') + expect(iconTypeForPdf).toBe('file-pdf') + + const iconTypeForDoc = iconsController.getIconForFileType('application/msword') + const iconTypeForDocx = iconsController.getIconForFileType( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + expect(iconTypeForDoc).toBe('file-doc') + expect(iconTypeForDocx).toBe('file-doc') + + const iconTypeForPpt = iconsController.getIconForFileType('application/vnd.ms-powerpoint') + const iconTypeForPptx = iconsController.getIconForFileType( + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ) + expect(iconTypeForPpt).toBe('file-ppt') + expect(iconTypeForPptx).toBe('file-ppt') + + const iconTypeForXls = iconsController.getIconForFileType('application/vnd.ms-excel') + const iconTypeForXlsx = iconsController.getIconForFileType( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.spreadsheet', + ) + expect(iconTypeForXls).toBe('file-xls') + expect(iconTypeForXlsx).toBe('file-xls') + + const iconTypeForJpg = iconsController.getIconForFileType('image/jpeg') + const iconTypeForPng = iconsController.getIconForFileType('image/png') + expect(iconTypeForJpg).toBe('file-image') + expect(iconTypeForPng).toBe('file-image') + + const iconTypeForMpeg = iconsController.getIconForFileType('video/mpeg') + const iconTypeForMp4 = iconsController.getIconForFileType('video/mp4') + expect(iconTypeForMpeg).toBe('file-mov') + expect(iconTypeForMp4).toBe('file-mov') + + const iconTypeForWav = iconsController.getIconForFileType('audio/wav') + const iconTypeForMp3 = iconsController.getIconForFileType('audio/mp3') + expect(iconTypeForWav).toBe('file-music') + expect(iconTypeForMp3).toBe('file-music') + + const iconTypeForZip = iconsController.getIconForFileType('application/zip') + const iconTypeForRar = iconsController.getIconForFileType('application/vnd.rar') + const iconTypeForTar = iconsController.getIconForFileType('application/x-tar') + const iconTypeFor7z = iconsController.getIconForFileType('application/x-7z-compressed') + expect(iconTypeForZip).toBe('file-zip') + expect(iconTypeForRar).toBe('file-zip') + expect(iconTypeForTar).toBe('file-zip') + expect(iconTypeFor7z).toBe('file-zip') + }) + + it('should return fallback icon type for unsupported mimetypes', () => { + const iconForBin = iconsController.getIconForFileType('application/octet-stream') + expect(iconForBin).toBe('file-other') + + const iconForNoType = iconsController.getIconForFileType('') + expect(iconForNoType).toBe('file-other') + }) + }) +}) diff --git a/packages/snjs/lib/Client/IconsController.ts b/packages/snjs/lib/Client/IconsController.ts new file mode 100644 index 000000000..9e284537d --- /dev/null +++ b/packages/snjs/lib/Client/IconsController.ts @@ -0,0 +1,61 @@ +import { NoteType } from '@standardnotes/features' +import { IconType } from '@Lib/Types/IconType' + +export class IconsController { + getIconForFileType(type: string): IconType { + let iconType: IconType = 'file-other' + + if (type === 'application/pdf') { + iconType = 'file-pdf' + } + + if (/word/.test(type)) { + iconType = 'file-doc' + } + + if (/powerpoint|presentation/.test(type)) { + iconType = 'file-ppt' + } + + if (/excel|spreadsheet/.test(type)) { + iconType = 'file-xls' + } + + if (/^image\//.test(type)) { + iconType = 'file-image' + } + + if (/^video\//.test(type)) { + iconType = 'file-mov' + } + + if (/^audio\//.test(type)) { + iconType = 'file-music' + } + + if (/(zip)|([tr]ar)|(7z)/.test(type)) { + iconType = 'file-zip' + } + + return iconType + } + + getIconAndTintForNoteType(noteType?: NoteType): [IconType, number] { + switch (noteType) { + case NoteType.RichText: + return ['rich-text', 1] + case NoteType.Markdown: + return ['markdown', 2] + case NoteType.Authentication: + return ['authenticator', 6] + case NoteType.Spreadsheet: + return ['spreadsheets', 5] + case NoteType.Task: + return ['tasks', 3] + case NoteType.Code: + return ['code', 4] + default: + return ['plain-text', 1] + } + } +} diff --git a/packages/snjs/lib/Client/ItemGroupController.ts b/packages/snjs/lib/Client/ItemGroupController.ts new file mode 100644 index 000000000..c97ee80d7 --- /dev/null +++ b/packages/snjs/lib/Client/ItemGroupController.ts @@ -0,0 +1,125 @@ +import { ApplicationEvent } from '../Application/Event' +import { FileItem, PrefKey, SNNote } from '@standardnotes/models' +import { removeFromArray } from '@standardnotes/utils' +import { SNApplication } from '../Application/Application' +import { NoteViewController } from './NoteViewController' +import { FileViewController } from './FileViewController' +import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions' + +type ItemControllerGroupChangeCallback = (activeController: NoteViewController | FileViewController | undefined) => void + +type CreateItemControllerOptions = FileItem | SNNote | TemplateNoteViewControllerOptions + +export class ItemGroupController { + public itemControllers: (NoteViewController | FileViewController)[] = [] + private addTagHierarchy: boolean + changeObservers: ItemControllerGroupChangeCallback[] = [] + eventObservers: (() => void)[] = [] + + constructor(private application: SNApplication) { + this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true) + + this.eventObservers.push( + application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { + this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true) + }), + ) + } + + public deinit(): void { + ;(this.application as unknown) = undefined + + this.eventObservers.forEach((removeObserver) => { + removeObserver() + }) + + this.changeObservers.length = 0 + + for (const controller of this.itemControllers) { + this.closeItemController(controller, { notify: false }) + } + + this.itemControllers.length = 0 + } + + async createItemController(options: CreateItemControllerOptions): Promise { + if (this.activeItemViewController) { + this.closeItemController(this.activeItemViewController, { notify: false }) + } + + let controller!: NoteViewController | FileViewController + + if (options instanceof FileItem) { + const file = options + controller = new FileViewController(this.application, file) + } else if (options instanceof SNNote) { + const note = options + controller = new NoteViewController(this.application, note) + } else { + controller = new NoteViewController(this.application, undefined, options) + } + + this.itemControllers.push(controller) + + await controller.initialize(this.addTagHierarchy) + + this.notifyObservers() + + return controller + } + + public closeItemController( + controller: NoteViewController | FileViewController, + { notify = true }: { notify: boolean } = { notify: true }, + ): void { + controller.deinit() + + removeFromArray(this.itemControllers, controller) + + if (notify) { + this.notifyObservers() + } + } + + closeActiveItemController(): void { + const activeController = this.activeItemViewController + + if (activeController) { + this.closeItemController(activeController, { notify: true }) + } + } + + closeAllItemControllers(): void { + for (const controller of this.itemControllers) { + this.closeItemController(controller, { notify: false }) + } + + this.notifyObservers() + } + + get activeItemViewController(): NoteViewController | FileViewController | undefined { + return this.itemControllers[0] + } + + /** + * Notifies observer when the active controller has changed. + */ + public addActiveControllerChangeObserver(callback: ItemControllerGroupChangeCallback): () => void { + this.changeObservers.push(callback) + + if (this.activeItemViewController) { + callback(this.activeItemViewController) + } + + const thislessChangeObservers = this.changeObservers + return () => { + removeFromArray(thislessChangeObservers, callback) + } + } + + private notifyObservers(): void { + for (const observer of this.changeObservers) { + observer(this.activeItemViewController) + } + } +} diff --git a/packages/snjs/lib/Client/ItemViewControllerInterface.ts b/packages/snjs/lib/Client/ItemViewControllerInterface.ts new file mode 100644 index 000000000..3918f90f9 --- /dev/null +++ b/packages/snjs/lib/Client/ItemViewControllerInterface.ts @@ -0,0 +1,8 @@ +import { SNNote, FileItem } from '@standardnotes/models' + +export interface ItemViewControllerInterface { + item: SNNote | FileItem + + deinit: () => void + initialize(addTagHierarchy?: boolean): Promise +} diff --git a/packages/snjs/lib/Client/NoteViewController.ts b/packages/snjs/lib/Client/NoteViewController.ts new file mode 100644 index 000000000..2bb7e98d6 --- /dev/null +++ b/packages/snjs/lib/Client/NoteViewController.ts @@ -0,0 +1,208 @@ +import { + NoteMutator, + SNNote, + SNTag, + NoteContent, + DecryptedItemInterface, + PayloadEmitSource, +} from '@standardnotes/models' +import { removeFromArray } from '@standardnotes/utils' +import { ContentType } from '@standardnotes/common' +import { UuidString } from '@Lib/Types/UuidString' +import { SNApplication } from '../Application/Application' +import { + STRING_SAVING_WHILE_DOCUMENT_HIDDEN, + STRING_INVALID_NOTE, + NOTE_PREVIEW_CHAR_LIMIT, + STRING_ELLIPSES, + SAVE_TIMEOUT_NO_DEBOUNCE, + SAVE_TIMEOUT_DEBOUNCE, +} from './Types' +import { ItemViewControllerInterface } from './ItemViewControllerInterface' +import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions' + +export type EditorValues = { + title: string + text: string +} + +export class NoteViewController implements ItemViewControllerInterface { + public item!: SNNote + public dealloced = false + private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = [] + private removeStreamObserver?: () => void + public isTemplateNote = false + private saveTimeout?: ReturnType + private defaultTitle: string | undefined + private defaultTag: UuidString | undefined + + constructor( + private application: SNApplication, + item?: SNNote, + templateNoteOptions?: TemplateNoteViewControllerOptions, + ) { + if (item) { + this.item = item + } + + if (templateNoteOptions) { + this.defaultTitle = templateNoteOptions.title + this.defaultTag = templateNoteOptions.tag + } + } + + deinit(): void { + this.dealloced = true + this.removeStreamObserver?.() + ;(this.removeStreamObserver as unknown) = undefined + ;(this.application as unknown) = undefined + ;(this.item as unknown) = undefined + + this.innerValueChangeObservers.length = 0 + + this.saveTimeout = undefined + } + + async initialize(addTagHierarchy: boolean): Promise { + if (!this.item) { + const note = this.application.mutator.createTemplateItem(ContentType.Note, { + text: '', + title: this.defaultTitle || '', + references: [], + }) + + this.isTemplateNote = true + this.item = note + + if (this.defaultTag) { + const tag = this.application.items.findItem(this.defaultTag) as SNTag + await this.application.items.addTagToNote(note, tag, addTagHierarchy) + } + + this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush) + } + + this.streamItems() + } + + private notifyObservers(note: SNNote, source: PayloadEmitSource): void { + for (const observer of this.innerValueChangeObservers) { + observer(note, source) + } + } + + private streamItems() { + this.removeStreamObserver = this.application.streamItems( + ContentType.Note, + ({ changed, inserted, source }) => { + if (this.dealloced) { + return + } + + const notes = changed.concat(inserted) + + const matchingNote = notes.find((item) => { + return item.uuid === this.item.uuid + }) + + if (matchingNote) { + this.isTemplateNote = false + this.item = matchingNote + this.notifyObservers(matchingNote, source) + } + }, + ) + } + + public insertTemplatedNote(): Promise { + this.isTemplateNote = false + return this.application.mutator.insertItem(this.item) + } + + /** + * Register to be notified when the controller's note's inner values change + * (and thus a new object reference is created) + */ + public addNoteInnerValueChangeObserver(callback: (note: SNNote, source: PayloadEmitSource) => void): () => void { + this.innerValueChangeObservers.push(callback) + + if (this.item) { + callback(this.item, PayloadEmitSource.InitialObserverRegistrationPush) + } + + const thislessChangeObservers = this.innerValueChangeObservers + return () => { + removeFromArray(thislessChangeObservers, callback) + } + } + + /** + * @param bypassDebouncer Calling save will debounce by default. You can pass true to save + * immediately. + * @param isUserModified This field determines if the item will be saved as a user + * modification, thus updating the user modified date displayed in the UI + * @param dontUpdatePreviews Whether this change should update the note's plain and HTML + * preview. + * @param customMutate A custom mutator function. + */ + public async save(dto: { + editorValues: EditorValues + bypassDebouncer?: boolean + isUserModified?: boolean + dontUpdatePreviews?: boolean + customMutate?: (mutator: NoteMutator) => void + }): Promise { + const title = dto.editorValues.title + const text = dto.editorValues.text + const isTemplate = this.isTemplateNote + + if (typeof document !== 'undefined' && document.hidden) { + void this.application.alertService.alert(STRING_SAVING_WHILE_DOCUMENT_HIDDEN) + return + } + + if (isTemplate) { + await this.insertTemplatedNote() + } + + if (!this.application.items.findItem(this.item.uuid)) { + void this.application.alertService.alert(STRING_INVALID_NOTE) + return + } + + await this.application.mutator.changeItem( + this.item, + (mutator) => { + const noteMutator = mutator as NoteMutator + if (dto.customMutate) { + dto.customMutate(noteMutator) + } + noteMutator.title = title + noteMutator.text = text + + if (!dto.dontUpdatePreviews) { + const noteText = text || '' + const truncate = noteText.length > NOTE_PREVIEW_CHAR_LIMIT + const substring = noteText.substring(0, NOTE_PREVIEW_CHAR_LIMIT) + const previewPlain = substring + (truncate ? STRING_ELLIPSES : '') + + // eslint-disable-next-line camelcase + noteMutator.preview_plain = previewPlain + // eslint-disable-next-line camelcase + noteMutator.preview_html = undefined + } + }, + dto.isUserModified, + ) + + if (this.saveTimeout) { + clearTimeout(this.saveTimeout) + } + + const noDebounce = dto.bypassDebouncer || this.application.noAccount() + const syncDebouceMs = noDebounce ? SAVE_TIMEOUT_NO_DEBOUNCE : SAVE_TIMEOUT_DEBOUNCE + this.saveTimeout = setTimeout(() => { + void this.application.sync.sync() + }, syncDebouceMs) + } +} diff --git a/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts b/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts new file mode 100644 index 000000000..1fe67688e --- /dev/null +++ b/packages/snjs/lib/Client/TemplateNoteViewControllerOptions.ts @@ -0,0 +1,6 @@ +import { UuidString } from '@Lib/Types/UuidString' + +export type TemplateNoteViewControllerOptions = { + title?: string + tag?: UuidString +} diff --git a/packages/snjs/lib/Client/Types.ts b/packages/snjs/lib/Client/Types.ts new file mode 100644 index 000000000..1f4ddcbbd --- /dev/null +++ b/packages/snjs/lib/Client/Types.ts @@ -0,0 +1,8 @@ +export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN = + 'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.' +export const STRING_INVALID_NOTE = + "The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note." +export const STRING_ELLIPSES = '...' +export const NOTE_PREVIEW_CHAR_LIMIT = 80 +export const SAVE_TIMEOUT_DEBOUNCE = 350 +export const SAVE_TIMEOUT_NO_DEBOUNCE = 100 diff --git a/packages/snjs/lib/Client/index.ts b/packages/snjs/lib/Client/index.ts new file mode 100644 index 000000000..aea7adb7a --- /dev/null +++ b/packages/snjs/lib/Client/index.ts @@ -0,0 +1,4 @@ +export * from './IconsController' +export * from './NoteViewController' +export * from './FileViewController' +export * from './ItemGroupController' diff --git a/packages/snjs/lib/Hosts.ts b/packages/snjs/lib/Hosts.ts new file mode 100644 index 000000000..1ba17f90e --- /dev/null +++ b/packages/snjs/lib/Hosts.ts @@ -0,0 +1,40 @@ +export const APPLICATION_DEFAULT_HOSTS = [ + 'api.standardnotes.com', + 'api-dev.standardnotes.com', + 'sync.standardnotes.org', + 'syncing-server-demo.standardnotes.com', +] + +export const FILES_DEFAULT_HOSTS = ['files.standardnotes.com', 'files-dev.standardnotes.com'] + +export const TRUSTED_FEATURE_HOSTS = [ + 'api-dev.standardnotes.com', + 'api.standardnotes.com', + 'extensions.standardnotes.com', + 'extensions.standardnotes.org', + 'extensions-server-dev.standardnotes.org', + 'extensions-server-dev.standardnotes.com', + 'features.standardnotes.com', +] + +export enum ExtensionsServerURL { + Dev = 'https://extensions-server-dev.standardnotes.org', + Prod = 'https://extensions.standardnotes.org', +} + +const LocalHost = 'localhost' + +export function isUrlFirstParty(url: string): boolean { + try { + const { host } = new URL(url) + return host.startsWith(LocalHost) || APPLICATION_DEFAULT_HOSTS.includes(host) || FILES_DEFAULT_HOSTS.includes(host) + } catch (_err) { + return false + } +} + +export const PROD_OFFLINE_FEATURES_URL = 'https://api.standardnotes.com/v1/offline/features' + +export const LEGACY_PROD_EXT_ORIGIN = 'https://extensions.standardnotes.org' + +export const TRUSTED_CUSTOM_EXTENSIONS_HOSTS = ['listed.to'] diff --git a/packages/snjs/lib/Log.ts b/packages/snjs/lib/Log.ts new file mode 100644 index 000000000..17ca85f08 --- /dev/null +++ b/packages/snjs/lib/Log.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +export class SNLog { + static log(...message: any): void { + this.onLog(...message) + } + static error(error: T): T { + this.onError(error) + return error + } + static onLog: (...message: any) => void + static onError: (error: Error) => void +} diff --git a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts new file mode 100644 index 000000000..bdeeca746 --- /dev/null +++ b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts @@ -0,0 +1,124 @@ +import { ItemManager } from '@Lib/Services' +import { TagsToFoldersMigrationApplicator } from './TagsToFolders' + +const itemManagerMock = (tagTitles: string[]) => { + const mockTag = (title: string) => ({ + title, + uuid: title, + parentId: undefined, + }) + + const mock = { + getItems: jest.fn().mockReturnValue(tagTitles.map(mockTag)), + findOrCreateTagParentChain: jest.fn(), + changeItem: jest.fn(), + } + + return mock +} + +describe('folders component to hierarchy', () => { + it('should produce a valid hierarchy in the simple case', async () => { + const titles = ['a', 'a.b', 'a.b.c'] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(2) + expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a']) + expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a', 'b']) + + expect(changeItemCalls.length).toEqual(2) + expect(changeItemCalls[0][0].uuid).toEqual('a.b') + expect(changeItemCalls[1][0].uuid).toEqual('a.b.c') + }) + + it('should not touch flat hierarchies', async () => { + const titles = ['a', 'x', 'y', 'z'] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(0) + + expect(changeItemCalls.length).toEqual(0) + }) + + it('should work despite cloned tags', async () => { + const titles = ['a.b', 'c', 'a.b'] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(2) + expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a']) + expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a']) + + expect(changeItemCalls.length).toEqual(2) + expect(changeItemCalls[0][0].uuid).toEqual('a.b') + expect(changeItemCalls[0][0].uuid).toEqual('a.b') + }) + + it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async () => { + const titles = ['y.2', 'w.3', 'y'] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(2) + expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['w']) + expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['y']) + + expect(changeItemCalls.length).toEqual(2) + expect(changeItemCalls[0][0].uuid).toEqual('w.3') + expect(changeItemCalls[1][0].uuid).toEqual('y.2') + }) + + it('skip prefixed names', async () => { + const titles = ['.something', '.something...something'] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(0) + expect(changeItemCalls.length).toEqual(0) + }) + + it('skip not-supported names', async () => { + const titles = [ + 'something.', + 'something..', + 'something..another.thing', + 'a.b.c', + 'a', + 'something..another.thing..anyway', + ] + + const itemManager = itemManagerMock(titles) + await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager) + + const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls + const changeItemCalls = itemManager.changeItem.mock.calls + + expect(findOrCreateTagParentChainCalls.length).toEqual(1) + expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a', 'b']) + + expect(changeItemCalls.length).toEqual(1) + expect(changeItemCalls[0][0].uuid).toEqual('a.b.c') + }) +}) diff --git a/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts new file mode 100644 index 000000000..3e0ae719c --- /dev/null +++ b/packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts @@ -0,0 +1,50 @@ +import { SNTag, TagMutator, TagFolderDelimitter } from '@standardnotes/models' +import { ItemManager } from '@Lib/Services' +import { lastElement, sortByKey, withoutLastElement } from '@standardnotes/utils' +import { ContentType } from '@standardnotes/common' + +export class TagsToFoldersMigrationApplicator { + public static isApplicableToCurrentData(itemManager: ItemManager): boolean { + const tags = itemManager.getItems(ContentType.Tag) + for (const tag of tags) { + if (tag.title.includes(TagFolderDelimitter) && !tag.parentId) { + return true + } + } + + return false + } + + public static async run(itemManager: ItemManager): Promise { + const tags = itemManager.getItems(ContentType.Tag) as SNTag[] + const sortedTags = sortByKey(tags, 'title') + + for (const tag of sortedTags) { + const hierarchy = tag.title.split(TagFolderDelimitter) + const hasSimpleTitle = hierarchy.length === 1 + const hasParent = !!tag.parentId + const hasUnsupportedTitle = hierarchy.some((title) => title.length === 0) + + if (hasParent || hasSimpleTitle || hasUnsupportedTitle) { + continue + } + + const parents = withoutLastElement(hierarchy) + const newTitle = lastElement(hierarchy) + + if (!newTitle) { + return + } + + const parent = await itemManager.findOrCreateTagParentChain(parents) + + await itemManager.changeItem(tag, (mutator: TagMutator) => { + mutator.title = newTitle + + if (parent) { + mutator.makeChildOf(parent) + } + }) + } + } +} diff --git a/packages/snjs/lib/Migrations/Base.ts b/packages/snjs/lib/Migrations/Base.ts new file mode 100644 index 000000000..cb319880b --- /dev/null +++ b/packages/snjs/lib/Migrations/Base.ts @@ -0,0 +1,247 @@ +import { AnyKeyParamsContent } from '@standardnotes/common' +import { SNLog } from '@Lib/Log' +import { EncryptedPayload, EncryptedTransferPayload, isErrorDecryptingPayload } from '@standardnotes/models' +import { Challenge } from '../Services/Challenge' +import { KeychainRecoveryStrings, SessionStrings } from '../Services/Api/Messages' +import { PreviousSnjsVersion1_0_0, PreviousSnjsVersion2_0_0, SnjsVersion } from '../Version' +import { Migration } from '@Lib/Migrations/Migration' +import { + RawStorageKey, + namespacedKey, + ApplicationStage, + ChallengeValidation, + ChallengeReason, + ChallengePrompt, +} from '@standardnotes/services' +import { isNullOrUndefined } from '@standardnotes/utils' +import { CreateReader } from './StorageReaders/Functions' +import { StorageReader } from './StorageReaders/Reader' +import { ContentTypeUsesRootKeyEncryption } from '@standardnotes/encryption' + +/** A key that was briefly present in Snjs version 2.0.0 but removed in 2.0.1 */ +const LastMigrationTimeStampKey2_0_0 = 'last_migration_timestamp' + +/** + * The base migration always runs during app initialization. It is meant as a way + * to set up all other migrations. + */ +export class BaseMigration extends Migration { + private reader!: StorageReader + private didPreRun = false + private memoizedNeedsKeychainRepair?: boolean + + public async preRun() { + await this.storeVersionNumber() + this.didPreRun = true + } + + protected registerStageHandlers() { + this.registerStageHandler(ApplicationStage.PreparingForLaunch_0, async () => { + if (await this.needsKeychainRepair()) { + await this.repairMissingKeychain() + } + this.markDone() + }) + } + + private getStoredVersion() { + const storageKey = namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion) + return this.services.deviceInterface.getRawStorageValue(storageKey) + } + + /** + * In Snjs 1.x, and Snjs 2.0.0, version numbers were not stored (as they were introduced + * in 2.0.1). Because migrations can now rely on this value, we want to establish a base + * value if we do not find it in storage. + */ + private async storeVersionNumber() { + const storageKey = namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion) + const version = await this.getStoredVersion() + if (!version) { + /** Determine if we are 1.0.0 or 2.0.0 */ + /** If any of these keys exist in raw storage, we are coming from a 1.x architecture */ + const possibleLegacyKeys = ['migrations', 'ephemeral', 'user', 'cachedThemes', 'syncToken', 'encryptedStorage'] + let hasLegacyValue = false + for (const legacyKey of possibleLegacyKeys) { + const value = await this.services.deviceInterface.getRawStorageValue(legacyKey) + if (value) { + hasLegacyValue = true + break + } + } + if (hasLegacyValue) { + /** Coming from 1.0.0 */ + await this.services.deviceInterface.setRawStorageValue(storageKey, PreviousSnjsVersion1_0_0) + } else { + /** Coming from 2.0.0 (which did not store version) OR is brand new application */ + const migrationKey = namespacedKey(this.services.identifier, LastMigrationTimeStampKey2_0_0) + const migrationValue = await this.services.deviceInterface.getRawStorageValue(migrationKey) + const is_2_0_0_application = !isNullOrUndefined(migrationValue) + if (is_2_0_0_application) { + await this.services.deviceInterface.setRawStorageValue(storageKey, PreviousSnjsVersion2_0_0) + await this.services.deviceInterface.removeRawStorageValue(LastMigrationTimeStampKey2_0_0) + } else { + /** Is new application, use current version as not to run any migrations */ + await this.services.deviceInterface.setRawStorageValue(storageKey, SnjsVersion) + } + } + } + } + + private async loadReader() { + if (this.reader) { + return + } + + const version = (await this.getStoredVersion()) as string + this.reader = CreateReader( + version, + this.services.deviceInterface, + this.services.identifier, + this.services.environment, + ) + } + + /** + * If the keychain is empty, and the user does not have a passcode, + * AND there appear to be stored account key params, this indicates + * a launch where the keychain was wiped due to restoring device + * from cloud backup which did not include keychain. This typically occurs + * on mobile when restoring from iCloud, but we'll also follow this same behavior + * on desktop/web as well, since we recently introduced keychain to desktop. + * + * We must prompt user for account password, and validate based on ability to decrypt + * an item. We cannot validate based on storage because 1.x mobile applications did + * not use encrypted storage, although we did on 2.x. But instead of having two methods + * of validations best to use one that works on both. + * + * The item is randomly chosen, but for 2.x applications, it must be an items key item + * (since only item keys are encrypted directly with account password) + */ + + public async needsKeychainRepair() { + if (this.memoizedNeedsKeychainRepair != undefined) { + return this.memoizedNeedsKeychainRepair + } + + if (!this.didPreRun) { + throw Error('Attempting to access specialized function before prerun') + } + + if (!this.reader) { + await this.loadReader() + } + + const usesKeychain = this.reader.usesKeychain + if (!usesKeychain) { + /** Doesn't apply if this version did not use a keychain to begin with */ + this.memoizedNeedsKeychainRepair = false + return this.memoizedNeedsKeychainRepair + } + + const rawAccountParams = await this.reader.getAccountKeyParams() + const hasAccountKeyParams = !isNullOrUndefined(rawAccountParams) + if (!hasAccountKeyParams) { + /** Doesn't apply if account is not involved */ + this.memoizedNeedsKeychainRepair = false + return this.memoizedNeedsKeychainRepair + } + + const hasPasscode = await this.reader.hasPasscode() + if (hasPasscode) { + /** Doesn't apply if using passcode, as keychain would be bypassed in that case */ + this.memoizedNeedsKeychainRepair = false + return this.memoizedNeedsKeychainRepair + } + + const accountKeysMissing = !(await this.reader.hasNonWrappedAccountKeys()) + if (!accountKeysMissing) { + this.memoizedNeedsKeychainRepair = false + return this.memoizedNeedsKeychainRepair + } + + this.memoizedNeedsKeychainRepair = true + return this.memoizedNeedsKeychainRepair + } + + private async repairMissingKeychain() { + const version = (await this.getStoredVersion()) as string + const rawAccountParams = await this.reader.getAccountKeyParams() + + /** Challenge for account password */ + const challenge = new Challenge( + [new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder, true)], + ChallengeReason.Custom, + false, + KeychainRecoveryStrings.Title, + KeychainRecoveryStrings.Text, + ) + + return new Promise((resolve) => { + this.services.challengeService.addChallengeObserver(challenge, { + onNonvalidatedSubmit: async (challengeResponse) => { + const password = challengeResponse.values[0].value as string + const accountParams = this.services.protocolService.createKeyParams(rawAccountParams as AnyKeyParamsContent) + const rootKey = await this.services.protocolService.computeRootKey(password, accountParams) + + /** Choose an item to decrypt */ + const allItems = ( + await this.services.deviceInterface.getAllRawDatabasePayloads( + this.services.identifier, + ) + ).map((p) => new EncryptedPayload(p)) + + let itemToDecrypt = allItems.find((item) => { + return ContentTypeUsesRootKeyEncryption(item.content_type) + }) + + if (!itemToDecrypt) { + /** If no root key encrypted item, just choose any item */ + itemToDecrypt = allItems[0] + } + + if (!itemToDecrypt) { + throw SNLog.error(Error('Attempting keychain recovery validation but no items present.')) + } + + const decryptedPayload = await this.services.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [itemToDecrypt], + key: rootKey, + }, + }) + + if (isErrorDecryptingPayload(decryptedPayload)) { + /** Wrong password, try again */ + this.services.challengeService.setValidationStatusForChallenge( + challenge, + challengeResponse.values[0], + false, + ) + } else { + /** + * If decryption succeeds, store the generated account key where it is expected, + * either in top-level keychain in 1.0.0, and namespaced location in 2.0.0+. + */ + if (version === PreviousSnjsVersion1_0_0) { + /** Store in top level keychain */ + await this.services.deviceInterface.setLegacyRawKeychainValue({ + mk: rootKey.masterKey, + ak: rootKey.dataAuthenticationKey as string, + version: accountParams.version, + }) + } else { + /** Store in namespaced location */ + const rawKey = rootKey.getKeychainValue() + await this.services.deviceInterface.setNamespacedKeychainValue(rawKey, this.services.identifier) + } + resolve(true) + this.services.challengeService.completeChallenge(challenge) + } + }, + }) + + void this.services.challengeService.promptForChallengeResponse(challenge) + }) + } +} diff --git a/packages/snjs/lib/Migrations/Migration.ts b/packages/snjs/lib/Migrations/Migration.ts new file mode 100644 index 000000000..3bcfa02af --- /dev/null +++ b/packages/snjs/lib/Migrations/Migration.ts @@ -0,0 +1,60 @@ +import { Challenge } from '../Services/Challenge' +import { MigrationServices } from './MigrationServices' +import { ApplicationStage, ChallengeValidation, ChallengeReason, ChallengePrompt } from '@standardnotes/services' + +type StageHandler = () => Promise + +export abstract class Migration { + private stageHandlers: Partial> = {} + private onDoneHandler?: () => void + + constructor(protected services: MigrationServices) { + this.registerStageHandlers() + } + + public static version(): string { + throw 'Must override migration version' + } + + protected abstract registerStageHandlers(): void + + protected registerStageHandler(stage: ApplicationStage, handler: StageHandler) { + this.stageHandlers[stage] = handler + } + + protected markDone() { + this.onDoneHandler?.() + this.onDoneHandler = undefined + } + + protected async promptForPasscodeUntilCorrect(validationCallback: (passcode: string) => Promise) { + const challenge = new Challenge([new ChallengePrompt(ChallengeValidation.None)], ChallengeReason.Migration, false) + return new Promise((resolve) => { + this.services.challengeService.addChallengeObserver(challenge, { + onNonvalidatedSubmit: async (challengeResponse) => { + const value = challengeResponse.values[0] + const passcode = value.value as string + const valid = await validationCallback(passcode) + if (valid) { + this.services.challengeService.completeChallenge(challenge) + resolve(passcode) + } else { + this.services.challengeService.setValidationStatusForChallenge(challenge, value, false) + } + }, + }) + void this.services.challengeService.promptForChallengeResponse(challenge) + }) + } + + onDone(callback: () => void) { + this.onDoneHandler = callback + } + + async handleStage(stage: ApplicationStage): Promise { + const handler = this.stageHandlers[stage] + if (handler) { + await handler() + } + } +} diff --git a/packages/snjs/lib/Migrations/MigrationServices.ts b/packages/snjs/lib/Migrations/MigrationServices.ts new file mode 100644 index 000000000..404425f6c --- /dev/null +++ b/packages/snjs/lib/Migrations/MigrationServices.ts @@ -0,0 +1,20 @@ +import { SNSessionManager } from '../Services/Session/SessionManager' +import { ApplicationIdentifier } from '@standardnotes/common' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { EncryptionService } from '@standardnotes/encryption' +import { DeviceInterface, InternalEventBusInterface, Environment } from '@standardnotes/services' +import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services' + +export type MigrationServices = { + protocolService: EncryptionService + deviceInterface: DeviceInterface + storageService: DiskStorageService + challengeService: ChallengeService + sessionManager: SNSessionManager + itemManager: ItemManager + singletonManager: SNSingletonManager + featuresService: SNFeaturesService + environment: Environment + identifier: ApplicationIdentifier + internalEventBus: InternalEventBusInterface +} diff --git a/packages/snjs/lib/Migrations/StorageReaders/Functions.ts b/packages/snjs/lib/Migrations/StorageReaders/Functions.ts new file mode 100644 index 000000000..81dd2ed82 --- /dev/null +++ b/packages/snjs/lib/Migrations/StorageReaders/Functions.ts @@ -0,0 +1,34 @@ +import { ApplicationIdentifier } from '@standardnotes/common' +import { compareSemVersions, isRightVersionGreaterThanLeft } from '@Lib/Version' +import { DeviceInterface, Environment } from '@standardnotes/services' +import { StorageReader } from './Reader' +import * as ReaderClasses from './Versions' + +function ReaderClassForVersion( + version: string, +): typeof ReaderClasses.StorageReader2_0_0 | typeof ReaderClasses.StorageReader1_0_0 { + /** Sort readers by newest first */ + const allReaders = Object.values(ReaderClasses).sort((a, b) => { + return compareSemVersions(a.version(), b.version()) * -1 + }) + for (const reader of allReaders) { + if (reader.version() === version) { + return reader + } + if (isRightVersionGreaterThanLeft(reader.version(), version)) { + return reader + } + } + + throw Error(`Cannot find reader for version ${version}`) +} + +export function CreateReader( + version: string, + deviceInterface: DeviceInterface, + identifier: ApplicationIdentifier, + environment: Environment, +): StorageReader { + const readerClass = ReaderClassForVersion(version) + return new readerClass(deviceInterface, identifier, environment) +} diff --git a/packages/snjs/lib/Migrations/StorageReaders/Reader.ts b/packages/snjs/lib/Migrations/StorageReaders/Reader.ts new file mode 100644 index 000000000..9f4058ec2 --- /dev/null +++ b/packages/snjs/lib/Migrations/StorageReaders/Reader.ts @@ -0,0 +1,31 @@ +import { ApplicationIdentifier } from '@standardnotes/common' +import { DeviceInterface, Environment } from '@standardnotes/services' + +/** + * A storage reader reads storage via a device interface + * given a specific version of SNJS + */ +export abstract class StorageReader { + constructor( + protected deviceInterface: DeviceInterface, + protected identifier: ApplicationIdentifier, + protected environment: Environment, + ) {} + + public static version(): string { + throw Error('Must override') + } + + public abstract getAccountKeyParams(): Promise + + /** + * Returns true if the state of storage has account keys present + * in version-specific storage (either keychain or raw storage) + */ + public abstract hasNonWrappedAccountKeys(): Promise + + public abstract hasPasscode(): Promise + + /** Whether this version used the keychain to store keys */ + public abstract usesKeychain(): boolean +} diff --git a/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader1_0_0.ts b/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader1_0_0.ts new file mode 100644 index 000000000..24ab4a212 --- /dev/null +++ b/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader1_0_0.ts @@ -0,0 +1,48 @@ +import { isNullOrUndefined } from '@standardnotes/utils' +import { isEnvironmentMobile } from '@Lib/Application/Platforms' +import { PreviousSnjsVersion1_0_0 } from '../../../Version' +import { isMobileDevice, LegacyKeys1_0_0 } from '@standardnotes/services' +import { StorageReader } from '../Reader' + +export class StorageReader1_0_0 extends StorageReader { + static override version() { + return PreviousSnjsVersion1_0_0 + } + + public async getAccountKeyParams() { + return this.deviceInterface.getJsonParsedRawStorageValue(LegacyKeys1_0_0.AllAccountKeyParamsKey) + } + + /** + * In 1.0.0, web uses raw storage for unwrapped account key, and mobile uses + * the keychain + */ + public async hasNonWrappedAccountKeys() { + if (isMobileDevice(this.deviceInterface)) { + const value = await this.deviceInterface.getRawKeychainValue() + return !isNullOrUndefined(value) + } else { + const value = await this.deviceInterface.getRawStorageValue('mk') + return !isNullOrUndefined(value) + } + } + + public async hasPasscode() { + if (isEnvironmentMobile(this.environment)) { + const rawPasscodeParams = await this.deviceInterface.getJsonParsedRawStorageValue( + LegacyKeys1_0_0.MobilePasscodeParamsKey, + ) + return !isNullOrUndefined(rawPasscodeParams) + } else { + const encryptedStorage = await this.deviceInterface.getJsonParsedRawStorageValue( + LegacyKeys1_0_0.WebEncryptedStorageKey, + ) + return !isNullOrUndefined(encryptedStorage) + } + } + + /** Keychain was not used on desktop/web in 1.0.0 */ + public usesKeychain() { + return isEnvironmentMobile(this.environment) ? true : false + } +} diff --git a/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader2_0_0.ts b/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader2_0_0.ts new file mode 100644 index 000000000..ff841b26c --- /dev/null +++ b/packages/snjs/lib/Migrations/StorageReaders/Versions/Reader2_0_0.ts @@ -0,0 +1,46 @@ +import { isNullOrUndefined } from '@standardnotes/utils' +import { RawStorageKey, StorageKey, namespacedKey, ValueModesKeys } from '@standardnotes/services' +import { StorageReader } from '../Reader' +import { PreviousSnjsVersion2_0_0 } from '@Lib/Version' + +export class StorageReader2_0_0 extends StorageReader { + static override version() { + return PreviousSnjsVersion2_0_0 + } + + private async getStorage() { + const storageKey = namespacedKey(this.identifier, RawStorageKey.StorageObject) + const storage = await this.deviceInterface.getRawStorageValue(storageKey) + const values = storage ? JSON.parse(storage) : undefined + return values + } + + private async getNonWrappedValue(key: string) { + const values = await this.getStorage() + if (!values) { + return undefined + } + return values[ValueModesKeys.Nonwrapped]?.[key] + } + + /** + * In 2.0.0+, account key params are stored in NonWrapped storage + */ + public async getAccountKeyParams() { + return this.getNonWrappedValue(StorageKey.RootKeyParams) + } + + public async hasNonWrappedAccountKeys() { + const value = await this.deviceInterface.getNamespacedKeychainValue(this.identifier) + return !isNullOrUndefined(value) + } + + public async hasPasscode() { + const wrappedRootKey = await this.getNonWrappedValue(StorageKey.WrappedRootKey) + return !isNullOrUndefined(wrappedRootKey) + } + + public usesKeychain() { + return true + } +} diff --git a/packages/snjs/lib/Migrations/StorageReaders/Versions/index.ts b/packages/snjs/lib/Migrations/StorageReaders/Versions/index.ts new file mode 100644 index 000000000..68c37d73d --- /dev/null +++ b/packages/snjs/lib/Migrations/StorageReaders/Versions/index.ts @@ -0,0 +1,2 @@ +export { StorageReader2_0_0 } from './Reader2_0_0' +export { StorageReader1_0_0 } from './Reader1_0_0' diff --git a/packages/snjs/lib/Migrations/Versions/2_0_0.ts b/packages/snjs/lib/Migrations/Versions/2_0_0.ts new file mode 100644 index 000000000..7a4186653 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_0_0.ts @@ -0,0 +1,725 @@ +import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common' +import { JwtSession } from '../../Services/Session/Sessions/JwtSession' +import { Migration } from '@Lib/Migrations/Migration' +import { MigrationServices } from '../MigrationServices' +import { PreviousSnjsVersion2_0_0 } from '../../Version' +import { SNRootKey, CreateNewRootKey } from '@standardnotes/encryption' +import { DiskStorageService } from '../../Services/Storage/DiskStorageService' +import { StorageReader1_0_0 } from '../StorageReaders/Versions/Reader1_0_0' +import * as Models from '@standardnotes/models' +import * as Services from '@standardnotes/services' +import * as Utils from '@standardnotes/utils' +import { isEnvironmentMobile, isEnvironmentWebOrDesktop } from '@Lib/Application/Platforms' +import { + getIncrementedDirtyIndex, + LegacyMobileKeychainStructure, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { isMobileDevice } from '@standardnotes/services' + +interface LegacyStorageContent extends Models.ItemContent { + storage: unknown +} + +interface LegacyAccountKeysValue { + ak: string + mk: string + version: string + jwt: string +} + +interface LegacyRootKeyContent extends Models.RootKeyContent { + accountKeys?: LegacyAccountKeysValue +} + +const LEGACY_SESSION_TOKEN_KEY = 'jwt' + +export class Migration2_0_0 extends Migration { + private legacyReader!: StorageReader1_0_0 + + constructor(services: MigrationServices) { + super(services) + this.legacyReader = new StorageReader1_0_0( + this.services.deviceInterface, + this.services.identifier, + this.services.environment, + ) + } + + static override version() { + return PreviousSnjsVersion2_0_0 + } + + protected registerStageHandlers() { + this.registerStageHandler(Services.ApplicationStage.PreparingForLaunch_0, async () => { + if (isEnvironmentWebOrDesktop(this.services.environment)) { + await this.migrateStorageStructureForWebDesktop() + } else if (isEnvironmentMobile(this.services.environment)) { + await this.migrateStorageStructureForMobile() + } + }) + this.registerStageHandler(Services.ApplicationStage.StorageDecrypted_09, async () => { + await this.migrateArbitraryRawStorageToManagedStorageAllPlatforms() + if (isEnvironmentMobile(this.services.environment)) { + await this.migrateMobilePreferences() + } + await this.migrateSessionStorage() + await this.deleteLegacyStorageValues() + }) + this.registerStageHandler(Services.ApplicationStage.LoadingDatabase_11, async () => { + await this.createDefaultItemsKeyForAllPlatforms() + this.markDone() + }) + } + + /** + * Web + * Migrates legacy storage structure into new managed format. + * If encrypted storage exists, we need to first decrypt it with the passcode. + * Then extract the account key from it. Then, encrypt storage with the + * account key. Then encrypt the account key with the passcode and store it + * within the new storage format. + * + * Generate note: We do not use the keychain if passcode is available. + */ + private async migrateStorageStructureForWebDesktop() { + const deviceInterface = this.services.deviceInterface + const newStorageRawStructure: Services.StorageValuesObject = { + [Services.ValueModesKeys.Wrapped]: {} as Models.LocalStorageEncryptedContextualPayload, + [Services.ValueModesKeys.Unwrapped]: {}, + [Services.ValueModesKeys.Nonwrapped]: {}, + } + const rawAccountKeyParams = (await this.legacyReader.getAccountKeyParams()) as AnyKeyParamsContent + /** Could be null if no account, or if account and storage is encrypted */ + if (rawAccountKeyParams) { + newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyParams] = rawAccountKeyParams + } + const encryptedStorage = (await deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.WebEncryptedStorageKey, + )) as Models.EncryptedTransferPayload + + if (encryptedStorage) { + const encryptedStoragePayload = new Models.EncryptedPayload(encryptedStorage) + + const passcodeResult = await this.webDesktopHelperGetPasscodeKeyAndDecryptEncryptedStorage( + encryptedStoragePayload, + ) + + const passcodeKey = passcodeResult.key + const decryptedStoragePayload = passcodeResult.decryptedStoragePayload + const passcodeParams = passcodeResult.keyParams + + newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyWrapperKeyParams] = passcodeParams.getPortableValue() + + const rawStorageValueStore = Utils.Copy(decryptedStoragePayload.content.storage) + const storageValueStore: Record = Utils.jsonParseEmbeddedKeys(rawStorageValueStore) + /** Store previously encrypted auth_params into new nonwrapped value key */ + + const accountKeyParams = storageValueStore[Services.LegacyKeys1_0_0.AllAccountKeyParamsKey] as AnyKeyParamsContent + newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyParams] = accountKeyParams + + let keyToEncryptStorageWith = passcodeKey + /** Extract account key (mk, pw, ak) if it exists */ + const hasAccountKeys = !Utils.isNullOrUndefined(storageValueStore.mk) + + if (hasAccountKeys) { + const { accountKey, wrappedKey } = await this.webDesktopHelperExtractAndWrapAccountKeysFromValueStore( + passcodeKey, + accountKeyParams, + storageValueStore, + ) + keyToEncryptStorageWith = accountKey + newStorageRawStructure.nonwrapped[Services.StorageKey.WrappedRootKey] = wrappedKey + } + + /** Encrypt storage with proper key */ + newStorageRawStructure.wrapped = await this.webDesktopHelperEncryptStorage( + keyToEncryptStorageWith, + decryptedStoragePayload, + storageValueStore, + ) + } else { + /** + * No encrypted storage, take account keys (if they exist) out of raw storage + * and place them in the keychain. */ + const ak = await this.services.deviceInterface.getRawStorageValue('ak') + const mk = await this.services.deviceInterface.getRawStorageValue('mk') + + if (ak || mk) { + const version = rawAccountKeyParams.version || (await this.getFallbackRootKeyVersion()) + + const accountKey = CreateNewRootKey({ + masterKey: mk as string, + dataAuthenticationKey: ak as string, + version: version, + keyParams: rawAccountKeyParams, + }) + await this.services.deviceInterface.setNamespacedKeychainValue( + accountKey.getKeychainValue(), + this.services.identifier, + ) + } + } + + /** Persist storage under new key and structure */ + await this.allPlatformHelperSetStorageStructure(newStorageRawStructure) + } + + /** + * Helper + * All platforms + */ + private async allPlatformHelperSetStorageStructure(rawStructure: Services.StorageValuesObject) { + const newStructure = DiskStorageService.DefaultValuesObject( + rawStructure.wrapped, + rawStructure.unwrapped, + rawStructure.nonwrapped, + ) as Partial + + newStructure[Services.ValueModesKeys.Unwrapped] = undefined + + await this.services.deviceInterface.setRawStorageValue( + Services.namespacedKey(this.services.identifier, Services.RawStorageKey.StorageObject), + JSON.stringify(newStructure), + ) + } + + /** + * Helper + * Web/desktop only + */ + private async webDesktopHelperGetPasscodeKeyAndDecryptEncryptedStorage( + encryptedPayload: Models.EncryptedPayloadInterface, + ) { + const rawPasscodeParams = (await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.WebPasscodeParamsKey, + )) as AnyKeyParamsContent + const passcodeParams = this.services.protocolService.createKeyParams(rawPasscodeParams) + + /** Decrypt it with the passcode */ + let decryptedStoragePayload: + | Models.DecryptedPayloadInterface + | Models.EncryptedPayloadInterface = encryptedPayload + let passcodeKey: SNRootKey | undefined + + await this.promptForPasscodeUntilCorrect(async (candidate: string) => { + passcodeKey = await this.services.protocolService.computeRootKey(candidate, passcodeParams) + decryptedStoragePayload = await this.services.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [encryptedPayload], + key: passcodeKey, + }, + }) + + return !Models.isErrorDecryptingPayload(decryptedStoragePayload) + }) + + return { + decryptedStoragePayload: + decryptedStoragePayload as unknown as Models.DecryptedPayloadInterface, + key: passcodeKey as SNRootKey, + keyParams: passcodeParams, + } + } + + /** + * Helper + * Web/desktop only + */ + private async webDesktopHelperExtractAndWrapAccountKeysFromValueStore( + passcodeKey: SNRootKey, + accountKeyParams: AnyKeyParamsContent, + storageValueStore: Record, + ) { + const version = accountKeyParams?.version || (await this.getFallbackRootKeyVersion()) + const accountKey = CreateNewRootKey({ + masterKey: storageValueStore.mk as string, + dataAuthenticationKey: storageValueStore.ak as string, + version: version, + keyParams: accountKeyParams, + }) + + delete storageValueStore.mk + delete storageValueStore.pw + delete storageValueStore.ak + + const accountKeyPayload = accountKey.payload + + /** Encrypt account key with passcode */ + const encryptedAccountKey = await this.services.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [accountKeyPayload], + key: passcodeKey, + }, + }) + return { + accountKey: accountKey, + wrappedKey: Models.CreateEncryptedLocalStorageContextPayload(encryptedAccountKey), + } + } + + /** + * Helper + * Web/desktop only + * Encrypt storage with account key + */ + async webDesktopHelperEncryptStorage( + key: SNRootKey, + decryptedStoragePayload: Models.DecryptedPayloadInterface, + storageValueStore: Record, + ) { + const wrapped = await this.services.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [ + decryptedStoragePayload.copy({ + content_type: ContentType.EncryptedStorage, + content: storageValueStore as unknown as Models.ItemContent, + }), + ], + key: key, + }, + }) + + return Models.CreateEncryptedLocalStorageContextPayload(wrapped) + } + + /** + * Mobile + * On mobile legacy structure is mostly similar to new structure, + * in that the account key is encrypted with the passcode. But mobile did + * not have encrypted storage, so we simply need to transfer all existing + * storage values into new managed structure. + * + * In version <= 3.0.16 on mobile, encrypted account keys were stored in the keychain + * under `encryptedAccountKeys`. In 3.0.17 a migration was introduced that moved this value + * to storage under key `encrypted_account_keys`. We need to anticipate the keys being in + * either location. + * + * If no account but passcode only, the only thing we stored on mobile + * previously was keys.offline.pw and keys.offline.timing in the keychain + * that we compared against for valid decryption. + * In the new version, we know a passcode is correct if it can decrypt storage. + * As part of the migration, we’ll need to request the raw passcode from user, + * compare it against the keychain offline.pw value, and if correct, + * migrate storage to new structure, and encrypt with passcode key. + * + * If account only, take the value in the keychain, and rename the values + * (i.e mk > masterKey). + * @access private + */ + async migrateStorageStructureForMobile() { + Utils.assert(isMobileDevice(this.services.deviceInterface)) + + const keychainValue = + (await this.services.deviceInterface.getRawKeychainValue()) as unknown as LegacyMobileKeychainStructure + + const wrappedAccountKey = ((await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobileWrappedRootKeyKey, + )) || keychainValue?.encryptedAccountKeys) as Models.EncryptedTransferPayload + + const rawAccountKeyParams = (await this.legacyReader.getAccountKeyParams()) as AnyKeyParamsContent + + const rawPasscodeParams = (await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobilePasscodeParamsKey, + )) as AnyKeyParamsContent + + const firstRunValue = await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.NonwrappedStorageKey.MobileFirstRun, + ) + + const rawStructure: Services.StorageValuesObject = { + [Services.ValueModesKeys.Nonwrapped]: { + [Services.StorageKey.WrappedRootKey]: wrappedAccountKey, + /** A 'hash' key may be present from legacy versions that should be deleted */ + [Services.StorageKey.RootKeyWrapperKeyParams]: Utils.omitByCopy(rawPasscodeParams, ['hash' as never]), + [Services.StorageKey.RootKeyParams]: rawAccountKeyParams, + [Services.NonwrappedStorageKey.MobileFirstRun]: firstRunValue, + }, + [Services.ValueModesKeys.Unwrapped]: {}, + [Services.ValueModesKeys.Wrapped]: {} as Models.LocalStorageDecryptedContextualPayload, + } + + const biometricPrefs = (await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobileBiometricsPrefs, + )) as { enabled: boolean; timing: unknown } + + if (biometricPrefs) { + rawStructure.nonwrapped[Services.StorageKey.BiometricsState] = biometricPrefs.enabled + rawStructure.nonwrapped[Services.StorageKey.MobileBiometricsTiming] = biometricPrefs.timing + } + + const passcodeKeyboardType = await this.services.deviceInterface.getRawStorageValue( + Services.LegacyKeys1_0_0.MobilePasscodeKeyboardType, + ) + + if (passcodeKeyboardType) { + rawStructure.nonwrapped[Services.StorageKey.MobilePasscodeKeyboardType] = passcodeKeyboardType + } + + if (rawPasscodeParams) { + const passcodeParams = this.services.protocolService.createKeyParams(rawPasscodeParams) + const getPasscodeKey = async () => { + let passcodeKey: SNRootKey | undefined + + await this.promptForPasscodeUntilCorrect(async (candidate: string) => { + passcodeKey = await this.services.protocolService.computeRootKey(candidate, passcodeParams) + + const pwHash = keychainValue?.offline?.pw + + if (pwHash) { + return passcodeKey.serverPassword === pwHash + } else { + /** + * Fallback decryption if keychain is missing for some reason. If account, + * validate by attempting to decrypt wrapped account key. Otherwise, validate + * by attempting to decrypt random item. + * */ + if (wrappedAccountKey) { + const decryptedAcctKey = await this.services.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [new Models.EncryptedPayload(wrappedAccountKey)], + key: passcodeKey, + }, + }) + return !Models.isErrorDecryptingPayload(decryptedAcctKey) + } else { + const item = ( + await this.services.deviceInterface.getAllRawDatabasePayloads(this.services.identifier) + )[0] as Models.EncryptedTransferPayload + + if (!item) { + throw Error('Passcode only migration aborting due to missing keychain.offline.pw') + } + + const decryptedPayload = await this.services.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [new Models.EncryptedPayload(item)], + key: passcodeKey, + }, + }) + return !Models.isErrorDecryptingPayload(decryptedPayload) + } + } + }) + + return passcodeKey as SNRootKey + } + + rawStructure.nonwrapped[Services.StorageKey.MobilePasscodeTiming] = keychainValue?.offline?.timing + + if (wrappedAccountKey) { + /** + * Account key is encrypted with passcode. Inside, the accountKey is located inside + * content.accountKeys. We want to unembed these values to main content, rename + * with proper property names, wrap again, and store in new rawStructure. + */ + const passcodeKey = await getPasscodeKey() + const payload = new Models.EncryptedPayload(wrappedAccountKey) + const unwrappedAccountKey = await this.services.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [payload], + key: passcodeKey, + }, + }) + + if (Models.isErrorDecryptingPayload(unwrappedAccountKey)) { + return + } + + const accountKeyContent = unwrappedAccountKey.content.accountKeys as LegacyAccountKeysValue + + const version = + accountKeyContent.version || rawAccountKeyParams?.version || (await this.getFallbackRootKeyVersion()) + + const newAccountKey = unwrappedAccountKey.copy({ + content: Models.FillItemContent({ + masterKey: accountKeyContent.mk, + dataAuthenticationKey: accountKeyContent.ak, + version: version as ProtocolVersion, + keyParams: rawAccountKeyParams, + accountKeys: undefined, + }), + }) + + const newWrappedAccountKey = await this.services.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [newAccountKey], + key: passcodeKey, + }, + }) + rawStructure.nonwrapped[Services.StorageKey.WrappedRootKey] = + Models.CreateEncryptedLocalStorageContextPayload(newWrappedAccountKey) + + if (accountKeyContent.jwt) { + /** Move the jwt to raw storage so that it can be migrated in `migrateSessionStorage` */ + void this.services.deviceInterface.setRawStorageValue(LEGACY_SESSION_TOKEN_KEY, accountKeyContent.jwt) + } + await this.services.deviceInterface.clearRawKeychainValue() + } else if (!wrappedAccountKey) { + /** Passcode only, no account */ + const passcodeKey = await getPasscodeKey() + const payload = new Models.DecryptedPayload({ + uuid: Utils.UuidGenerator.GenerateUuid(), + content: Models.FillItemContent(rawStructure.unwrapped), + content_type: ContentType.EncryptedStorage, + ...PayloadTimestampDefaults(), + }) + + /** Encrypt new storage.unwrapped structure with passcode */ + const wrapped = await this.services.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [payload], + key: passcodeKey, + }, + }) + rawStructure.wrapped = Models.CreateEncryptedLocalStorageContextPayload(wrapped) + + await this.services.deviceInterface.clearRawKeychainValue() + } + } else { + /** No passcode, potentially account. Migrate keychain property keys. */ + const hasAccount = !Utils.isNullOrUndefined(keychainValue?.mk) + if (hasAccount) { + const accountVersion = + (keychainValue.version as ProtocolVersion) || + rawAccountKeyParams?.version || + (await this.getFallbackRootKeyVersion()) + + const accountKey = CreateNewRootKey({ + masterKey: keychainValue.mk, + dataAuthenticationKey: keychainValue.ak, + version: accountVersion, + keyParams: rawAccountKeyParams, + }) + + await this.services.deviceInterface.setNamespacedKeychainValue( + accountKey.getKeychainValue(), + this.services.identifier, + ) + + if (keychainValue.jwt) { + /** Move the jwt to raw storage so that it can be migrated in `migrateSessionStorage` */ + void this.services.deviceInterface.setRawStorageValue(LEGACY_SESSION_TOKEN_KEY, keychainValue.jwt) + } + } + } + + /** Move encrypted account key into place where it is now expected */ + await this.allPlatformHelperSetStorageStructure(rawStructure) + } + + /** + * If we are unable to determine a root key's version, due to missing version + * parameter from key params due to 001 or 002, we need to fallback to checking + * any encrypted payload and retrieving its version. + * + * If we are unable to garner any meaningful information, we will default to 002. + * + * (Previously we attempted to discern version based on presence of keys.ak; if ak, + * then 003, otherwise 002. However, late versions of 002 also inluded an ak, so this + * method can't be used. This method also didn't account for 001 versions.) + */ + private async getFallbackRootKeyVersion() { + const anyItem = ( + await this.services.deviceInterface.getAllRawDatabasePayloads(this.services.identifier) + )[0] as Models.EncryptedTransferPayload + + if (!anyItem) { + return ProtocolVersion.V002 + } + + const payload = new Models.EncryptedPayload(anyItem) + return payload.version || ProtocolVersion.V002 + } + + /** + * All platforms + * Migrate all previously independently stored storage keys into new + * managed approach. + */ + private async migrateArbitraryRawStorageToManagedStorageAllPlatforms() { + const allKeyValues = await this.services.deviceInterface.getAllRawStorageKeyValues() + const legacyKeys = Utils.objectToValueArray(Services.LegacyKeys1_0_0) + + const tryJsonParse = (value: string) => { + try { + return JSON.parse(value) + } catch (e) { + return value + } + } + + const applicationIdentifier = this.services.identifier + + for (const keyValuePair of allKeyValues) { + const key = keyValuePair.key + const value = keyValuePair.value + const isNameSpacedKey = + applicationIdentifier && applicationIdentifier.length > 0 && key.startsWith(applicationIdentifier) + if (legacyKeys.includes(key) || isNameSpacedKey) { + continue + } + if (!Utils.isNullOrUndefined(value)) { + /** + * Raw values should always have been json stringified. + * New values should always be objects/parsed. + */ + const newValue = tryJsonParse(value as string) + this.services.storageService.setValue(key, newValue) + } + } + } + + /** + * All platforms + * Deletes all StorageKey and LegacyKeys1_0_0 from root raw storage. + * @access private + */ + async deleteLegacyStorageValues() { + const miscKeys = [ + 'mk', + 'ak', + 'pw', + /** v1 unused key */ + 'encryptionKey', + /** v1 unused key */ + 'authKey', + 'jwt', + 'ephemeral', + 'cachedThemes', + ] + + const managedKeys = [ + ...Utils.objectToValueArray(Services.StorageKey), + ...Utils.objectToValueArray(Services.LegacyKeys1_0_0), + ...miscKeys, + ] + + for (const key of managedKeys) { + await this.services.deviceInterface.removeRawStorageValue(key) + } + } + + /** + * Mobile + * Migrate mobile preferences + */ + private async migrateMobilePreferences() { + const lastExportDate = await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobileLastExportDate, + ) + const doNotWarnUnsupportedEditors = await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobileDoNotWarnUnsupportedEditors, + ) + const legacyOptionsState = (await this.services.deviceInterface.getJsonParsedRawStorageValue( + Services.LegacyKeys1_0_0.MobileOptionsState, + )) as Record + + let migratedOptionsState = {} + + if (legacyOptionsState) { + const legacySortBy = legacyOptionsState.sortBy + migratedOptionsState = { + sortBy: + legacySortBy === 'updated_at' || legacySortBy === 'client_updated_at' + ? Models.CollectionSort.UpdatedAt + : legacySortBy, + sortReverse: legacyOptionsState.sortReverse ?? false, + hideNotePreview: legacyOptionsState.hidePreviews ?? false, + hideDate: legacyOptionsState.hideDates ?? false, + hideTags: legacyOptionsState.hideTags ?? false, + } + } + const preferences = { + ...migratedOptionsState, + lastExportDate: lastExportDate ?? undefined, + doNotShowAgainUnsupportedEditors: doNotWarnUnsupportedEditors ?? false, + } + await this.services.storageService.setValue(Services.StorageKey.MobilePreferences, preferences) + } + + /** + * All platforms + * Migrate previously stored session string token into object + * On mobile, JWTs were previously stored in storage, inside of the user object, + * but then custom-migrated to be stored in the keychain. We must account for + * both scenarios here in case a user did not perform the custom platform migration. + * On desktop/web, JWT was stored in storage. + */ + private migrateSessionStorage() { + const USER_OBJECT_KEY = 'user' + let currentToken = this.services.storageService.getValue(LEGACY_SESSION_TOKEN_KEY) + const user = this.services.storageService.getValue<{ jwt: string; server: string }>(USER_OBJECT_KEY) + + if (!currentToken) { + /** Try the user object */ + if (user) { + currentToken = user.jwt + } + } + + if (!currentToken) { + /** + * If we detect that a user object is present, but the jwt is missing, + * we'll fill the jwt value with a junk value just so we create a session. + * When the client attempts to talk to the server, the server will reply + * with invalid token error, and the client will automatically prompt to reauthenticate. + */ + const hasAccount = !Utils.isNullOrUndefined(user) + if (hasAccount) { + currentToken = 'junk-value' + } else { + return + } + } + + const session = new JwtSession(currentToken) + this.services.storageService.setValue(Services.StorageKey.Session, session) + + /** Server has to be migrated separately on mobile */ + if (isEnvironmentMobile(this.services.environment)) { + if (user && user.server) { + this.services.storageService.setValue(Services.StorageKey.ServerHost, user.server) + } + } + } + + /** + * All platforms + * Create new default items key from root key. + * Otherwise, when data is loaded, we won't be able to decrypt it + * without existence of an item key. This will mean that if this migration + * is run on two different platforms for the same user, they will create + * two new items keys. Which one they use to decrypt past items and encrypt + * future items doesn't really matter. + * @access private + */ + async createDefaultItemsKeyForAllPlatforms() { + const rootKey = this.services.protocolService.getRootKey() + if (rootKey) { + const rootKeyParams = await this.services.protocolService.getRootKeyParams() + /** If params are missing a version, it must be 001 */ + const fallbackVersion = ProtocolVersion.V001 + + const payload = new Models.DecryptedPayload({ + uuid: Utils.UuidGenerator.GenerateUuid(), + content_type: ContentType.ItemsKey, + content: Models.FillItemContentSpecialized({ + itemsKey: rootKey.masterKey, + dataAuthenticationKey: rootKey.dataAuthenticationKey, + version: rootKeyParams?.version || fallbackVersion, + }), + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + ...PayloadTimestampDefaults(), + }) + + const itemsKey = Models.CreateDecryptedItemFromPayload(payload) + + await this.services.itemManager.emitItemFromPayload( + itemsKey.payloadRepresentation(), + Models.PayloadEmitSource.LocalChanged, + ) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_0_15.ts b/packages/snjs/lib/Migrations/Versions/2_0_15.ts new file mode 100644 index 000000000..a36707957 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_0_15.ts @@ -0,0 +1,21 @@ +import { ApplicationStage } from '@standardnotes/services' +import { Migration } from '@Lib/Migrations/Migration' + +export class Migration2_0_15 extends Migration { + static override version(): string { + return '2.0.15' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => { + await this.createNewDefaultItemsKeyIfNecessary() + this.markDone() + }) + } + + private async createNewDefaultItemsKeyIfNecessary() { + if (this.services.protocolService.needsNewRootKeyBasedItemsKey()) { + await this.services.protocolService.createNewDefaultItemsKey() + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_20_0.ts b/packages/snjs/lib/Migrations/Versions/2_20_0.ts new file mode 100644 index 000000000..70beffec8 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_20_0.ts @@ -0,0 +1,26 @@ +import { Migration } from '@Lib/Migrations/Migration' +import { ContentType } from '@standardnotes/common' +import { ApplicationStage } from '@standardnotes/services' + +export class Migration2_20_0 extends Migration { + static override version(): string { + return '2.20.0' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => { + await this.deleteMfaItems() + this.markDone() + }) + } + + private async deleteMfaItems(): Promise { + const contentType = 'SF|MFA' as ContentType + const items = this.services.itemManager.getItems(contentType) + + for (const item of items) { + this.services.itemManager.removeItemLocally(item) + await this.services.storageService.deletePayloadWithId(item.uuid) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_36_0.ts b/packages/snjs/lib/Migrations/Versions/2_36_0.ts new file mode 100644 index 000000000..62743513c --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_36_0.ts @@ -0,0 +1,26 @@ +import { Migration } from '@Lib/Migrations/Migration' +import { ContentType } from '@standardnotes/common' +import { ApplicationStage } from '@standardnotes/services' + +export class Migration2_36_0 extends Migration { + static override version(): string { + return '2.36.0' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => { + await this.removeServerExtensionsLocally() + this.markDone() + }) + } + + private async removeServerExtensionsLocally(): Promise { + const contentType = 'SF|Extension' as ContentType + const items = this.services.itemManager.getItems(contentType) + + for (const item of items) { + this.services.itemManager.removeItemLocally(item) + await this.services.storageService.deletePayloadWithId(item.uuid) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_42_0.ts b/packages/snjs/lib/Migrations/Versions/2_42_0.ts new file mode 100644 index 000000000..0bf3ba07c --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_42_0.ts @@ -0,0 +1,30 @@ +import { ContentType } from '@standardnotes/common' +import { ApplicationStage } from '@standardnotes/services' +import { FeatureIdentifier } from '@standardnotes/features' +import { Migration } from '@Lib/Migrations/Migration' +import { SNTheme } from '@standardnotes/models' + +const NoDistractionIdentifier = 'org.standardnotes.theme-no-distraction' as FeatureIdentifier + +export class Migration2_42_0 extends Migration { + static override version(): string { + return '2.42.0' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => { + await this.deleteNoDistraction() + this.markDone() + }) + } + + private async deleteNoDistraction(): Promise { + const themes = (this.services.itemManager.getItems(ContentType.Theme) as SNTheme[]).filter((theme) => { + return theme.identifier === NoDistractionIdentifier + }) + + for (const theme of themes) { + await this.services.itemManager.setItemToBeDeleted(theme) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/2_7_0.ts b/packages/snjs/lib/Migrations/Versions/2_7_0.ts new file mode 100644 index 000000000..48059f090 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/2_7_0.ts @@ -0,0 +1,32 @@ +import { CompoundPredicate, Predicate, SNComponent } from '@standardnotes/models' +import { Migration } from '@Lib/Migrations/Migration' +import { ContentType } from '@standardnotes/common' +import { ApplicationStage } from '@standardnotes/services' + +export class Migration2_7_0 extends Migration { + static override version(): string { + return '2.7.0' + } + + protected registerStageHandlers(): void { + this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => { + await this.deleteBatchManagerSingleton() + this.markDone() + }) + } + + private async deleteBatchManagerSingleton() { + const batchMgrId = 'org.standardnotes.batch-manager' + + const batchMgrPred = new CompoundPredicate('and', [ + new Predicate('content_type', '=', ContentType.Component), + new Predicate('identifier', '=', batchMgrId), + ]) + + const batchMgrSingleton = this.services.singletonManager.findSingleton(ContentType.Component, batchMgrPred) + + if (batchMgrSingleton) { + await this.services.itemManager.setItemToBeDeleted(batchMgrSingleton) + } + } +} diff --git a/packages/snjs/lib/Migrations/Versions/index.ts b/packages/snjs/lib/Migrations/Versions/index.ts new file mode 100644 index 000000000..b065864e9 --- /dev/null +++ b/packages/snjs/lib/Migrations/Versions/index.ts @@ -0,0 +1,17 @@ +import { Migration2_0_0 } from './2_0_0' +import { Migration2_0_15 } from './2_0_15' +import { Migration2_7_0 } from './2_7_0' +import { Migration2_20_0 } from './2_20_0' +import { Migration2_36_0 } from './2_36_0' +import { Migration2_42_0 } from './2_42_0' + +export const MigrationClasses = [ + Migration2_0_0, + Migration2_0_15, + Migration2_7_0, + Migration2_20_0, + Migration2_36_0, + Migration2_42_0, +] + +export { Migration2_0_0, Migration2_0_15, Migration2_7_0, Migration2_20_0, Migration2_36_0, Migration2_42_0 } diff --git a/packages/snjs/lib/Migrations/index.ts b/packages/snjs/lib/Migrations/index.ts new file mode 100644 index 000000000..4974cdec9 --- /dev/null +++ b/packages/snjs/lib/Migrations/index.ts @@ -0,0 +1,2 @@ +export { BaseMigration } from './Base' +export * from './Versions' diff --git a/packages/snjs/lib/Services/Actions/ActionsService.ts b/packages/snjs/lib/Services/Actions/ActionsService.ts new file mode 100644 index 000000000..88a2c82f8 --- /dev/null +++ b/packages/snjs/lib/Services/Actions/ActionsService.ts @@ -0,0 +1,324 @@ +import { EncryptionService, SNRootKey } from '@standardnotes/encryption' +import { Challenge, ChallengeService } from '../Challenge' +import { ListedService } from '../Listed/ListedService' +import { ActionResponse, HttpResponse } from '@standardnotes/responses' +import { ContentType } from '@standardnotes/common' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { + SNActionsExtension, + Action, + ActionAccessType, + ActionsExtensionMutator, + MutationType, + CreateDecryptedItemFromPayload, + DecryptedItemInterface, + DecryptedPayloadInterface, + ActionExtensionContent, + EncryptedPayload, + isErrorDecryptingPayload, + CreateEncryptedBackupFileContextPayload, + EncryptedTransferPayload, +} from '@standardnotes/models' +import { SNSyncService } from '../Sync/SyncService' +import { PayloadManager } from '../Payloads/PayloadManager' +import { SNHttpService } from '../Api/HttpService' +import { + AbstractService, + DeviceInterface, + InternalEventBusInterface, + AlertService, + ChallengeValidation, + ChallengeReason, + ChallengePrompt, +} from '@standardnotes/services' + +/** + * The Actions Service allows clients to interact with action-based extensions. + * Action-based extensions are mostly RESTful actions that can push a local value or + * retrieve a remote value and act on it accordingly. + * There are 4 action types: + * `get`: performs a GET request on an endpoint to retrieve an item value, and merges the + * value onto the local item value. For example, you can GET an item's older revision + * value and replace the current value with the revision. + * `render`: performs a GET request, and displays the result in the UI. This action does not + * affect data unless action is taken explicitely in the UI after the data is presented. + * `show`: opens the action's URL in a browser. + * `post`: sends an item's data to a remote service. This is used for example by Listed + * to allow publishing a note to a user's blog. + */ +export class SNActionsService extends AbstractService { + private previousPasswords: string[] = [] + + constructor( + private itemManager: ItemManager, + private alertService: AlertService, + public deviceInterface: DeviceInterface, + private httpService: SNHttpService, + private payloadManager: PayloadManager, + private protocolService: EncryptionService, + private syncService: SNSyncService, + private challengeService: ChallengeService, + private listedService: ListedService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + this.previousPasswords = [] + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.deviceInterface as unknown) = undefined + ;(this.httpService as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.listedService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.syncService as unknown) = undefined + this.previousPasswords.length = 0 + super.deinit() + } + + public getExtensions(): SNActionsExtension[] { + const extensionItems = this.itemManager.getItems(ContentType.ActionsExtension) + const excludingListed = extensionItems.filter((extension) => !extension.isListedExtension) + return excludingListed + } + + public extensionsInContextOfItem(item: DecryptedItemInterface) { + return this.getExtensions().filter((ext) => { + return ext.supported_types.includes(item.content_type) || ext.actionsWithContextForItem(item).length > 0 + }) + } + + /** + * Loads an extension in the context of a certain item. + * The server then has the chance to respond with actions that are + * relevant just to this item. The response extension is not saved, + * just displayed as a one-time thing. + */ + public async loadExtensionInContextOfItem( + extension: SNActionsExtension, + item: DecryptedItemInterface, + ): Promise { + const params = { + content_type: item.content_type, + item_uuid: item.uuid, + } + + const response = (await this.httpService.getAbsolute(extension.url, params).catch((response) => { + console.error('Error loading extension', response) + return undefined + })) as ActionResponse + + if (!response) { + return + } + + const description = response.description || extension.description + const supported_types = response.supported_types || extension.supported_types + const actions = (response.actions || []) as Action[] + const mutator = new ActionsExtensionMutator(extension, MutationType.UpdateUserTimestamps) + + mutator.deprecation = response.deprecation + mutator.description = description + mutator.supported_types = supported_types + mutator.actions = actions + + const payloadResult = mutator.getResult() + + return CreateDecryptedItemFromPayload(payloadResult) as SNActionsExtension + } + + public async runAction(action: Action, item: DecryptedItemInterface): Promise { + let result + switch (action.verb) { + case 'render': + result = await this.handleRenderAction(action) + break + case 'show': + result = this.handleShowAction(action) + break + case 'post': + result = await this.handlePostAction(action, item) + break + default: + break + } + return result + } + + private async handleRenderAction(action: Action): Promise { + const response = await this.httpService + .getAbsolute(action.url) + .then(async (response) => { + const payload = await this.payloadByDecryptingResponse(response as ActionResponse) + if (payload) { + const item = CreateDecryptedItemFromPayload(payload) + return { + ...(response as ActionResponse), + item, + } + } else { + return undefined + } + }) + .catch((response) => { + const error = (response && response.error) || { + message: 'An issue occurred while processing this action. Please try again.', + } + void this.alertService.alert(error.message) + return { error } as HttpResponse + }) + + return response as ActionResponse + } + + private async payloadByDecryptingResponse( + response: ActionResponse, + rootKey?: SNRootKey, + triedPasswords: string[] = [], + ): Promise | undefined> { + if (!response.item || response.item.deleted || response.item.content == undefined) { + return undefined + } + + const payload = new EncryptedPayload(response.item as EncryptedTransferPayload) + + if (!payload.enc_item_key) { + void this.alertService.alert('This revision is missing its key and cannot be recovered.') + return + } + + let decryptedPayload = await this.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }) + + if (!isErrorDecryptingPayload(decryptedPayload)) { + return decryptedPayload + } + + if (rootKey) { + decryptedPayload = await this.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [payload], + key: rootKey, + }, + }) + if (!isErrorDecryptingPayload(decryptedPayload)) { + return decryptedPayload + } + } + + for (const itemsKey of this.itemManager.getDisplayableItemsKeys()) { + const decryptedPayload = await this.protocolService.decryptSplitSingle({ + usesItemsKey: { + items: [payload], + key: itemsKey, + }, + }) + + if (!isErrorDecryptingPayload(decryptedPayload)) { + return decryptedPayload + } + } + + const keyParamsData = response.keyParams || response.auth_params + if (!keyParamsData) { + /** + * In some cases revisions were missing auth params. + * Instruct the user to email us to get this remedied. + */ + void this.alertService.alert( + 'We were unable to decrypt this revision using your current keys, ' + + 'and this revision is missing metadata that would allow us to try different ' + + 'keys to decrypt it. This can likely be fixed with some manual intervention. ' + + 'Please email help@standardnotes.com for assistance.', + ) + return undefined + } + const keyParams = this.protocolService.createKeyParams(keyParamsData) + + /* Try previous passwords */ + for (const passwordCandidate of this.previousPasswords) { + if (triedPasswords.includes(passwordCandidate)) { + continue + } + + triedPasswords.push(passwordCandidate) + + const key = await this.protocolService.computeRootKey(passwordCandidate, keyParams) + if (!key) { + continue + } + + const nestedResponse = await this.payloadByDecryptingResponse(response, key, triedPasswords) + if (nestedResponse) { + return nestedResponse + } + } + + /** Prompt for other passwords */ + const password = await this.promptForLegacyPassword() + if (!password) { + return undefined + } + + if (this.previousPasswords.includes(password)) { + return undefined + } + + this.previousPasswords.push(password) + return this.payloadByDecryptingResponse(response, rootKey) + } + + private async promptForLegacyPassword(): Promise { + const challenge = new Challenge( + [new ChallengePrompt(ChallengeValidation.None, 'Previous Password', undefined, true)], + ChallengeReason.Custom, + true, + 'Unable to find key for revision. Please enter the account password you may have used at the time of the revision.', + ) + + const response = await this.challengeService.promptForChallengeResponse(challenge) + + return response?.getDefaultValue().value as string + } + + private async handlePostAction(action: Action, item: DecryptedItemInterface) { + const decrypted = action.access_type === ActionAccessType.Decrypted + const itemParams = await this.outgoingPayloadForItem(item, decrypted) + const params = { + items: [itemParams], + } + return this.httpService + .postAbsolute(action.url, params) + .then((response) => { + return response as ActionResponse + }) + .catch((response) => { + console.error('Action error response:', response) + void this.alertService.alert('An issue occurred while processing this action. Please try again.') + return response as ActionResponse + }) + } + + private handleShowAction(action: Action) { + void this.deviceInterface.openUrl(action.url) + return {} as ActionResponse + } + + private async outgoingPayloadForItem(item: DecryptedItemInterface, decrypted = false) { + if (decrypted) { + return item.payload.ejected() + } + + const encrypted = await this.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { items: [item.payload] }, + }) + + return CreateEncryptedBackupFileContextPayload(encrypted) + } +} diff --git a/packages/snjs/lib/Services/Api/ApiService.ts b/packages/snjs/lib/Services/Api/ApiService.ts new file mode 100644 index 000000000..9200daa94 --- /dev/null +++ b/packages/snjs/lib/Services/Api/ApiService.ts @@ -0,0 +1,903 @@ +import { FeatureDescription } from '@standardnotes/features' +import { isNullOrUndefined, joinPaths } from '@standardnotes/utils' +import { SettingName, SubscriptionSettingName } from '@standardnotes/settings' +import { Uuid, ErrorTag } from '@standardnotes/common' +import { + AbstractService, + ApiServiceInterface, + InternalEventBusInterface, + IntegrityApiInterface, + ItemsServerInterface, + StorageKey, + ApiServiceEvent, + MetaReceivedData, + DiagnosticInfo, + FilesApiInterface, + KeyValueStoreInterface, +} from '@standardnotes/services' +import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models' +import * as Responses from '@standardnotes/responses' +import { API_MESSAGE_FAILED_OFFLINE_ACTIVATION } from '@Lib/Services/Api/Messages' +import { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService' +import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' +import { Paths } from './Paths' +import { Session } from '../Session/Sessions/Session' +import { TokenSession } from '../Session/Sessions/TokenSession' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { UserServerInterface } from '../User/UserServerInterface' +import { UuidString } from '../../Types/UuidString' +import * as messages from '@Lib/Services/Api/Messages' +import merge from 'lodash/merge' +import { SettingsServerInterface } from '../Settings/SettingsServerInterface' +import { Strings } from '@Lib/Strings' +import { SNRootKeyParams } from '@standardnotes/encryption' +import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { HttpResponseMeta } from '@standardnotes/api' + +/** Legacy api version field to be specified in params when calling v0 APIs. */ +const V0_API_VERSION = '20200115' + +type InvalidSessionObserver = (revoked: boolean) => void + +export class SNApiService + extends AbstractService + implements + ApiServiceInterface, + FilesApiInterface, + IntegrityApiInterface, + ItemsServerInterface, + UserServerInterface, + SettingsServerInterface +{ + private session?: Session + public user?: Responses.User + private registering = false + private authenticating = false + private changing = false + private refreshingSession = false + private invalidSessionObserver?: InvalidSessionObserver + private filesHost?: string + + constructor( + private httpService: SNHttpService, + private storageService: DiskStorageService, + private host: string, + private inMemoryStore: KeyValueStoreInterface, + private crypto: PureCryptoInterface, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + override deinit(): void { + ;(this.httpService as unknown) = undefined + ;(this.storageService as unknown) = undefined + this.invalidSessionObserver = undefined + this.session = undefined + super.deinit() + } + + public setUser(user?: Responses.User): void { + this.user = user + } + + /** + * When a we receive a 401 error from the server, we'll notify the observer. + * Note that this applies only to sessions that are totally invalid. Sessions that + * are expired but can be renewed are still considered to be valid. In those cases, + * the server response is 498. + * If the session has been revoked, then the observer will have its first + * argument set to true. + */ + public setInvalidSessionObserver(observer: InvalidSessionObserver): void { + this.invalidSessionObserver = observer + } + + public loadHost(): void { + const storedValue = this.storageService.getValue(StorageKey.ServerHost) + this.host = + storedValue || + this.host || + (( + window as { + _default_sync_server?: string + } + )._default_sync_server as string) + } + + public async setHost(host: string): Promise { + this.host = host + this.storageService.setValue(StorageKey.ServerHost, host) + } + + public getHost(): string { + return this.host + } + + public isThirdPartyHostUsed(): boolean { + const applicationHost = this.getHost() || '' + return !isUrlFirstParty(applicationHost) + } + + public getFilesHost(): string { + if (!this.filesHost) { + throw Error('Attempting to access undefined filesHost') + } + return this.filesHost + } + + public setSession(session: Session, persist = true): void { + this.session = session + if (persist) { + this.storageService.setValue(StorageKey.Session, session) + } + } + + public getSession(): Session | undefined { + return this.session + } + + public get apiVersion() { + return V0_API_VERSION + } + + private params(inParams: Record): HttpParams { + const params = merge(inParams, { + [ApiEndpointParam.ApiVersion]: this.apiVersion, + }) + return params + } + + public createErrorResponse(message: string, status?: Responses.StatusCode): Responses.HttpResponse { + return { error: { message, status } } as Responses.HttpResponse + } + + private errorResponseWithFallbackMessage(response: Responses.HttpResponse, message: string) { + if (!response.error?.message) { + response.error = { + ...response.error, + status: response.error?.status ?? Responses.StatusCode.UnknownError, + message, + } + } + return response + } + + public processMetaObject(meta: HttpResponseMeta) { + if (meta.auth && meta.auth.userUuid && meta.auth.roles) { + void this.notifyEvent(ApiServiceEvent.MetaReceived, { + userUuid: meta.auth.userUuid, + userRoles: meta.auth.roles, + }) + } + + if (meta.server?.filesServerUrl) { + this.filesHost = meta.server?.filesServerUrl + } + } + + private processResponse(response: Responses.HttpResponse) { + if (response.meta) { + this.processMetaObject(response.meta) + } + } + + private async request(params: { + verb: HttpVerb + url: string + fallbackErrorMessage: string + params?: HttpParams + rawBytes?: Uint8Array + authentication?: string + customHeaders?: Record[] + responseType?: XMLHttpRequestResponseType + external?: boolean + }) { + try { + const response = await this.httpService.runHttp(params) + this.processResponse(response) + return response + } catch (errorResponse) { + return this.errorResponseWithFallbackMessage(errorResponse as Responses.HttpResponse, params.fallbackErrorMessage) + } + } + + /** + * @param mfaKeyPath The params path the server expects for authentication against + * a particular mfa challenge. A value of foo would mean the server + * would receive parameters as params['foo'] with value equal to mfaCode. + * @param mfaCode The mfa challenge response value. + */ + async getAccountKeyParams(dto: { + email: string + mfaKeyPath?: string + mfaCode?: string + }): Promise { + const codeVerifier = this.crypto.generateRandomKey(256) + this.inMemoryStore.setValue(StorageKey.CodeVerifier, codeVerifier) + + const codeChallenge = this.crypto.base64URLEncode(await this.crypto.sha256(codeVerifier)) + + const params = this.params({ + email: dto.email, + code_challenge: codeChallenge, + }) + + if (dto.mfaKeyPath !== undefined && dto.mfaCode !== undefined) { + params[dto.mfaKeyPath] = dto.mfaCode + } + + return this.request({ + verb: HttpVerb.Post, + url: joinPaths(this.host, Paths.v2.keyParams), + fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN, + params, + /** A session is optional here, if valid, endpoint bypasses 2FA and returns additional params */ + authentication: this.session?.authorizationValue, + }) + } + + async signIn(dto: { + email: string + serverPassword: string + ephemeral: boolean + }): Promise { + if (this.authenticating) { + return this.createErrorResponse(messages.API_MESSAGE_LOGIN_IN_PROGRESS) as Responses.SignInResponse + } + this.authenticating = true + const url = joinPaths(this.host, Paths.v2.signIn) + const params = this.params({ + email: dto.email, + password: dto.serverPassword, + ephemeral: dto.ephemeral, + code_verifier: this.inMemoryStore.getValue(StorageKey.CodeVerifier) as string, + }) + + const response = await this.request({ + verb: HttpVerb.Post, + url, + params, + fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN, + }) + + this.authenticating = false + + this.inMemoryStore.removeValue(StorageKey.CodeVerifier) + + return response + } + + signOut(): Promise { + const url = joinPaths(this.host, Paths.v1.signOut) + return this.httpService.postAbsolute(url, undefined, this.session?.authorizationValue).catch((errorResponse) => { + return errorResponse + }) as Promise + } + + async changeCredentials(parameters: { + userUuid: UuidString + currentServerPassword: string + newServerPassword: string + newKeyParams: SNRootKeyParams + newEmail?: string + }): Promise { + if (this.changing) { + return this.createErrorResponse(messages.API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS) + } + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + this.changing = true + const url = joinPaths(this.host, Paths.v1.changeCredentials(parameters.userUuid) as string) + const params = this.params({ + current_password: parameters.currentServerPassword, + new_password: parameters.newServerPassword, + new_email: parameters.newEmail, + ...parameters.newKeyParams.getPortableValue(), + }) + const response = await this.httpService + .putAbsolute(url, params, this.session?.authorizationValue) + .catch(async (errorResponse) => { + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Put, + url, + params, + }) + } + return this.errorResponseWithFallbackMessage( + errorResponse, + messages.API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL, + ) + }) + + this.processResponse(response) + + this.changing = false + return response + } + + public async deleteAccount(userUuid: string): Promise { + const url = joinPaths(this.host, Paths.v1.deleteAccount(userUuid)) + const response = await this.request({ + verb: HttpVerb.Delete, + url, + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.ServerErrorStrings.DeleteAccountError, + }) + return response + } + + async sync( + payloads: ServerSyncPushContextualPayload[], + lastSyncToken: string, + paginationToken: string, + limit: number, + ): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + const url = joinPaths(this.host, Paths.v1.sync) + const params = this.params({ + [ApiEndpointParam.SyncPayloads]: payloads, + [ApiEndpointParam.LastSyncToken]: lastSyncToken, + [ApiEndpointParam.PaginationToken]: paginationToken, + [ApiEndpointParam.SyncDlLimit]: limit, + }) + const response = await this.httpService + .postAbsolute(url, params, this.session?.authorizationValue) + .catch(async (errorResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Post, + url, + params, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + + return response + } + + private async refreshSessionThenRetryRequest(httpRequest: HttpRequest): Promise { + const sessionResponse = await this.refreshSession() + if (sessionResponse.error || isNullOrUndefined(sessionResponse.data)) { + return sessionResponse + } else { + return this.httpService + .runHttp({ + ...httpRequest, + authentication: this.session?.authorizationValue, + }) + .catch((errorResponse) => { + return errorResponse + }) + } + } + + async refreshSession(): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + this.refreshingSession = true + const url = joinPaths(this.host, Paths.v1.refreshSession) + const session = this.session as TokenSession + const params = this.params({ + access_token: session.accessToken, + refresh_token: session.refreshToken, + }) + const result = await this.httpService + .postAbsolute(url, params) + .then(async (response) => { + const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse) + await this.setSession(session) + this.processResponse(response) + return response + }) + .catch((errorResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL) + }) + this.refreshingSession = false + return result + } + + async getSessionsList(): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + const url = joinPaths(this.host, Paths.v1.sessions) + const response = await this.httpService + .getAbsolute(url, {}, this.session?.authorizationValue) + .catch(async (errorResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Get, + url, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + + return response + } + + async deleteSession(sessionId: UuidString): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + const url = joinPaths(this.host, Paths.v1.session(sessionId)) + const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService + .deleteAbsolute(url, { uuid: sessionId }, this.session?.authorizationValue) + .catch((error: Responses.HttpResponse) => { + const errorResponse = error as Responses.HttpResponse + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Delete, + url, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + return response + } + + async getItemRevisions(itemId: UuidString): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId)) + const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService + .getAbsolute(url, undefined, this.session?.authorizationValue) + .catch((errorResponse: Responses.HttpResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Get, + url, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + return response + } + + async getRevision( + entry: Responses.RevisionListEntry, + itemId: UuidString, + ): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError + } + const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid)) + const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService + .getAbsolute(url, undefined, this.session?.authorizationValue) + .catch((errorResponse: Responses.HttpResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Get, + url, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + return response + } + + async getUserFeatures(userUuid: UuidString): Promise { + const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid)) + const response = await this.httpService + .getAbsolute(url, undefined, this.session?.authorizationValue) + .catch((errorResponse: Responses.HttpResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest({ + verb: HttpVerb.Get, + url, + }) + } + return this.errorResponseWithFallbackMessage(errorResponse, messages.API_MESSAGE_GENERIC_SYNC_FAIL) + }) + this.processResponse(response) + return response + } + + private async tokenRefreshableRequest( + params: HttpRequest & { fallbackErrorMessage: string }, + ): Promise { + const preprocessingError = this.preprocessingError() + if (preprocessingError) { + return preprocessingError as T + } + const response: T | Responses.HttpResponse = await this.httpService + .runHttp(params) + .catch((errorResponse: Responses.HttpResponse) => { + this.preprocessAuthenticatedErrorResponse(errorResponse) + if (Responses.isErrorResponseExpiredToken(errorResponse)) { + return this.refreshSessionThenRetryRequest(params) + } + return this.errorResponseWithFallbackMessage(errorResponse, params.fallbackErrorMessage) + }) + this.processResponse(response) + return response as T + } + + async listSettings(userUuid: UuidString): Promise { + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url: joinPaths(this.host, Paths.v1.settings(userUuid)), + fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS, + authentication: this.session?.authorizationValue, + }) + } + + async updateSetting( + userUuid: UuidString, + settingName: string, + settingValue: string | null, + sensitive: boolean, + ): Promise { + const params = { + name: settingName, + value: settingValue, + sensitive: sensitive, + } + return this.tokenRefreshableRequest({ + verb: HttpVerb.Put, + url: joinPaths(this.host, Paths.v1.settings(userUuid)), + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_UPDATE_SETTINGS, + params, + }) + } + + async getSetting(userUuid: UuidString, settingName: SettingName): Promise { + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)), + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS, + }) + } + + async getSubscriptionSetting( + userUuid: UuidString, + settingName: SubscriptionSettingName, + ): Promise { + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url: joinPaths( + this.host, + Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName), + ), + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_GET_SETTINGS, + }) + } + + async deleteSetting(userUuid: UuidString, settingName: SettingName): Promise { + return this.tokenRefreshableRequest({ + verb: HttpVerb.Delete, + url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)), + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_UPDATE_SETTINGS, + }) + } + + async deleteRevision( + itemUuid: UuidString, + entry: Responses.RevisionListEntry, + ): Promise { + const url = joinPaths(this.host, Paths.v1.itemRevision(itemUuid, entry.uuid)) + const response = await this.tokenRefreshableRequest({ + verb: HttpVerb.Delete, + url, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_DELETE_REVISION, + authentication: this.session?.authorizationValue, + }) + return response + } + + public downloadFeatureUrl(url: string): Promise { + return this.request({ + verb: HttpVerb.Get, + url, + external: true, + fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INVALID_LOGIN, + }) + } + + public async getSubscription(userUuid: string): Promise { + const url = joinPaths(this.host, Paths.v1.subscription(userUuid)) + const response = await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url, + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_SUBSCRIPTION_INFO, + }) + return response + } + + public async getAvailableSubscriptions(): Promise< + Responses.HttpResponse | Responses.GetAvailableSubscriptionsResponse + > { + const url = joinPaths(this.host, Paths.v2.subscriptions) + const response = await this.request({ + verb: HttpVerb.Get, + url, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_SUBSCRIPTION_INFO, + }) + return response + } + + public async getNewSubscriptionToken(): Promise { + const url = joinPaths(this.host, Paths.v1.subscriptionTokens) + const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({ + verb: HttpVerb.Post, + url, + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_ACCESS_PURCHASE, + }) + return (response as Responses.PostSubscriptionTokensResponse).data?.token + } + + public async downloadOfflineFeaturesFromRepo( + repo: SNFeatureRepo, + ): Promise<{ features: FeatureDescription[] } | ClientDisplayableError> { + try { + const featuresUrl = repo.offlineFeaturesUrl + const extensionKey = repo.offlineKey + if (!featuresUrl || !extensionKey) { + throw Error('Cannot download offline repo without url and offlineKEy') + } + + const { host } = new URL(featuresUrl) + + if (!TRUSTED_FEATURE_HOSTS.includes(host)) { + return new ClientDisplayableError('This offline features host is not in the trusted allowlist.') + } + + const response: Responses.HttpResponse | Responses.GetOfflineFeaturesResponse = await this.request({ + verb: HttpVerb.Get, + url: featuresUrl, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_OFFLINE_FEATURES, + customHeaders: [{ key: 'x-offline-token', value: extensionKey }], + }) + + if (response.error) { + return ClientDisplayableError.FromError(response.error) + } + return { + features: (response as Responses.GetOfflineFeaturesResponse).data?.features || [], + } + } catch { + return new ClientDisplayableError(API_MESSAGE_FAILED_OFFLINE_ACTIVATION) + } + } + + public async registerForListedAccount(): Promise { + if (!this.user) { + throw Error('Cannot register for Listed without user account.') + } + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)), + fallbackErrorMessage: messages.API_MESSAGE_FAILED_LISTED_REGISTRATION, + authentication: this.session?.authorizationValue, + }) + } + + public async createFileValetToken( + remoteIdentifier: string, + operation: 'write' | 'read' | 'delete', + unencryptedFileSize?: number, + ): Promise { + const url = joinPaths(this.host, Paths.v1.createFileValetToken) + + const params: CreateValetTokenPayload = { + operation, + resources: [{ remoteIdentifier, unencryptedFileSize: unencryptedFileSize || 0 }], + } + + const response = await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url: url, + authentication: this.session?.authorizationValue, + fallbackErrorMessage: messages.API_MESSAGE_FAILED_CREATE_FILE_TOKEN, + params, + }) + + if (!response.data?.success) { + return new ClientDisplayableError(response.data?.reason as string, undefined, response.data?.reason as string) + } + + return response.data?.valetToken + } + + public async startUploadSession(apiToken: string): Promise { + const url = joinPaths(this.getFilesHost(), Paths.v1.startUploadSession) + + const response: Responses.HttpResponse | Responses.StartUploadSessionResponse = await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url, + customHeaders: [{ key: 'x-valet-token', value: apiToken }], + fallbackErrorMessage: Strings.Network.Files.FailedStartUploadSession, + }) + + return response as Responses.StartUploadSessionResponse + } + + public async deleteFile(apiToken: string): Promise { + const url = joinPaths(this.getFilesHost(), Paths.v1.deleteFile) + + const response: Responses.HttpResponse | Responses.StartUploadSessionResponse = await this.tokenRefreshableRequest({ + verb: HttpVerb.Delete, + url, + customHeaders: [{ key: 'x-valet-token', value: apiToken }], + fallbackErrorMessage: Strings.Network.Files.FailedDeleteFile, + }) + + return response as Responses.MinimalHttpResponse + } + + public async uploadFileBytes(apiToken: string, chunkId: number, encryptedBytes: Uint8Array): Promise { + if (chunkId === 0) { + throw Error('chunkId must start with 1') + } + const url = joinPaths(this.getFilesHost(), Paths.v1.uploadFileChunk) + + const response: Responses.HttpResponse | Responses.UploadFileChunkResponse = await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url, + rawBytes: encryptedBytes, + customHeaders: [ + { key: 'x-valet-token', value: apiToken }, + { key: 'x-chunk-id', value: chunkId.toString() }, + { key: 'Content-Type', value: 'application/octet-stream' }, + ], + fallbackErrorMessage: Strings.Network.Files.FailedUploadFileChunk, + }) + + return (response as Responses.UploadFileChunkResponse).success + } + + public async closeUploadSession(apiToken: string): Promise { + const url = joinPaths(this.getFilesHost(), Paths.v1.closeUploadSession) + + const response: Responses.HttpResponse | Responses.CloseUploadSessionResponse = await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url, + customHeaders: [{ key: 'x-valet-token', value: apiToken }], + fallbackErrorMessage: Strings.Network.Files.FailedCloseUploadSession, + }) + + return (response as Responses.CloseUploadSessionResponse).success + } + + public getFilesDownloadUrl(): string { + return joinPaths(this.getFilesHost(), Paths.v1.downloadFileChunk) + } + + public async downloadFile( + file: { encryptedChunkSizes: FileContent['encryptedChunkSizes'] }, + chunkIndex = 0, + apiToken: string, + contentRangeStart: number, + onBytesReceived: (bytes: Uint8Array) => Promise, + ): Promise { + const url = this.getFilesDownloadUrl() + const pullChunkSize = file.encryptedChunkSizes[chunkIndex] + + const response: Responses.HttpResponse | Responses.DownloadFileChunkResponse = + await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url, + customHeaders: [ + { key: 'x-valet-token', value: apiToken }, + { + key: 'x-chunk-size', + value: pullChunkSize.toString(), + }, + { key: 'range', value: `bytes=${contentRangeStart}-` }, + ], + fallbackErrorMessage: Strings.Network.Files.FailedDownloadFileChunk, + responseType: 'arraybuffer', + }) + + const contentRangeHeader = (>response.headers).get('content-range') + if (!contentRangeHeader) { + return new ClientDisplayableError('Could not obtain content-range header while downloading file chunk') + } + + const matches = contentRangeHeader.match(/(^[a-zA-Z][\w]*)\s+(\d+)\s?-\s?(\d+)?\s?\/?\s?(\d+|\*)?/) + if (!matches || matches.length !== 5) { + return new ClientDisplayableError('Malformed content-range header in response when downloading file chunk') + } + + const rangeStart = +matches[2] + const rangeEnd = +matches[3] + const totalSize = +matches[4] + + const bytesReceived = new Uint8Array(response.data as ArrayBuffer) + + await onBytesReceived(bytesReceived) + + if (rangeEnd < totalSize - 1) { + return this.downloadFile(file, ++chunkIndex, apiToken, rangeStart + pullChunkSize, onBytesReceived) + } + + return undefined + } + + async checkIntegrity(integrityPayloads: Responses.IntegrityPayload[]): Promise { + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Post, + url: joinPaths(this.host, Paths.v1.checkIntegrity), + params: { + integrityPayloads, + }, + fallbackErrorMessage: messages.API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL, + authentication: this.session?.authorizationValue, + }) + } + + async getSingleItem(itemUuid: Uuid): Promise { + return await this.tokenRefreshableRequest({ + verb: HttpVerb.Get, + url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)), + fallbackErrorMessage: messages.API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL, + authentication: this.session?.authorizationValue, + }) + } + + private preprocessingError() { + if (this.refreshingSession) { + return this.createErrorResponse(messages.API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS) + } + if (!this.session) { + return this.createErrorResponse(messages.API_MESSAGE_INVALID_SESSION) + } + return undefined + } + + /** Handle errored responses to authenticated requests */ + private preprocessAuthenticatedErrorResponse(response: Responses.HttpResponse) { + if (response.status === Responses.StatusCode.HttpStatusInvalidSession && this.session) { + this.invalidSessionObserver?.(response.error?.tag === ErrorTag.RevokedSession) + } + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + api: { + hasSession: this.session != undefined, + user: this.user, + registering: this.registering, + authenticating: this.authenticating, + changing: this.changing, + refreshingSession: this.refreshingSession, + filesHost: this.filesHost, + host: this.host, + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Api/HttpService.ts b/packages/snjs/lib/Services/Api/HttpService.ts new file mode 100644 index 000000000..2e989075a --- /dev/null +++ b/packages/snjs/lib/Services/Api/HttpService.ts @@ -0,0 +1,211 @@ +import { API_MESSAGE_RATE_LIMITED, UNKNOWN_ERROR } from './Messages' +import { HttpResponse, StatusCode } from '@standardnotes/responses' +import { isString } from '@standardnotes/utils' +import { SnjsVersion } from '@Lib/Version' +import { AbstractService, InternalEventBusInterface, Environment } from '@standardnotes/services' + +export enum HttpVerb { + Get = 'GET', + Post = 'POST', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', +} + +const REQUEST_READY_STATE_COMPLETED = 4 + +export type HttpParams = Record + +export type HttpRequest = { + url: string + params?: HttpParams + rawBytes?: Uint8Array + verb: HttpVerb + authentication?: string + customHeaders?: Record[] + responseType?: XMLHttpRequestResponseType + external?: boolean +} + +/** + * A non-SNJS specific wrapper for XMLHttpRequests + */ +export class SNHttpService extends AbstractService { + constructor( + private readonly environment: Environment, + private readonly appVersion: string, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public async getAbsolute(url: string, params?: HttpParams, authentication?: string): Promise { + return this.runHttp({ url, params, verb: HttpVerb.Get, authentication }) + } + + public async postAbsolute(url: string, params?: HttpParams, authentication?: string): Promise { + return this.runHttp({ url, params, verb: HttpVerb.Post, authentication }) + } + + public async putAbsolute(url: string, params?: HttpParams, authentication?: string): Promise { + return this.runHttp({ url, params, verb: HttpVerb.Put, authentication }) + } + + public async patchAbsolute(url: string, params: HttpParams, authentication?: string): Promise { + return this.runHttp({ url, params, verb: HttpVerb.Patch, authentication }) + } + + public async deleteAbsolute(url: string, params?: HttpParams, authentication?: string): Promise { + return this.runHttp({ url, params, verb: HttpVerb.Delete, authentication }) + } + + public async runHttp(httpRequest: HttpRequest): Promise { + const request = this.createXmlRequest(httpRequest) + + return this.runRequest(request, this.createRequestBody(httpRequest)) + } + + private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined { + if ( + httpRequest.params !== undefined && + [HttpVerb.Post, HttpVerb.Put, HttpVerb.Patch, HttpVerb.Delete].includes(httpRequest.verb) + ) { + return JSON.stringify(httpRequest.params) + } + + return httpRequest.rawBytes + } + + private createXmlRequest(httpRequest: HttpRequest) { + const request = new XMLHttpRequest() + if (httpRequest.params && httpRequest.verb === HttpVerb.Get && Object.keys(httpRequest.params).length > 0) { + httpRequest.url = this.urlForUrlAndParams(httpRequest.url, httpRequest.params) + } + request.open(httpRequest.verb, httpRequest.url, true) + request.responseType = httpRequest.responseType ?? '' + + if (!httpRequest.external) { + request.setRequestHeader('X-SNJS-Version', SnjsVersion) + + const appVersionHeaderValue = `${Environment[this.environment]}-${this.appVersion}` + request.setRequestHeader('X-Application-Version', appVersionHeaderValue) + + if (httpRequest.authentication) { + request.setRequestHeader('Authorization', 'Bearer ' + httpRequest.authentication) + } + } + + let contenTypeIsSet = false + if (httpRequest.customHeaders && httpRequest.customHeaders.length > 0) { + httpRequest.customHeaders.forEach(({ key, value }) => { + request.setRequestHeader(key, value) + if (key === 'Content-Type') { + contenTypeIsSet = true + } + }) + } + if (!contenTypeIsSet && !httpRequest.external) { + request.setRequestHeader('Content-Type', 'application/json') + } + + return request + } + + private async runRequest(request: XMLHttpRequest, body?: string | Uint8Array): Promise { + return new Promise((resolve, reject) => { + request.onreadystatechange = () => { + this.stateChangeHandlerForRequest(request, resolve, reject) + } + request.send(body) + }) + } + + private stateChangeHandlerForRequest( + request: XMLHttpRequest, + resolve: (response: HttpResponse) => void, + reject: (response: HttpResponse) => void, + ) { + if (request.readyState !== REQUEST_READY_STATE_COMPLETED) { + return + } + const httpStatus = request.status + const response: HttpResponse = { + status: httpStatus, + headers: new Map(), + } + + const responseHeaderLines = request + .getAllResponseHeaders() + ?.trim() + .split(/[\r\n]+/) + responseHeaderLines?.forEach((responseHeaderLine) => { + const parts = responseHeaderLine.split(': ') + const name = parts.shift() as string + const value = parts.join(': ') + + ;(>response.headers).set(name, value) + }) + + try { + if (httpStatus !== StatusCode.HttpStatusNoContent) { + let body + + const contentTypeHeader = response.headers?.get('content-type') || response.headers?.get('Content-Type') + + if (contentTypeHeader?.includes('application/json')) { + body = JSON.parse(request.responseText) + } else { + body = request.response + } + /** + * v0 APIs do not have a `data` top-level object. In such cases, mimic + * the newer response body style by putting all the top-level + * properties inside a `data` object. + */ + if (!body.data) { + response.data = body + } + if (!isString(body)) { + Object.assign(response, body) + } + } + } catch (error) { + console.error(error) + } + if (httpStatus >= StatusCode.HttpStatusMinSuccess && httpStatus <= StatusCode.HttpStatusMaxSuccess) { + resolve(response) + } else { + if (httpStatus === StatusCode.HttpStatusForbidden) { + response.error = { + message: API_MESSAGE_RATE_LIMITED, + status: httpStatus, + } + } else if (response.error == undefined) { + if (response.data == undefined || response.data.error == undefined) { + try { + response.error = { message: request.responseText || UNKNOWN_ERROR, status: httpStatus } + } catch (error) { + response.error = { message: UNKNOWN_ERROR, status: httpStatus } + } + } else { + response.error = response.data.error + } + } + reject(response) + } + } + + private urlForUrlAndParams(url: string, params: HttpParams) { + const keyValueString = Object.keys(params) + .map((key) => { + return key + '=' + encodeURIComponent(params[key] as string) + }) + .join('&') + + if (url.includes('?')) { + return url + '&' + keyValueString + } else { + return url + '?' + keyValueString + } + } +} diff --git a/packages/snjs/lib/Services/Api/Messages.ts b/packages/snjs/lib/Services/Api/Messages.ts new file mode 100644 index 000000000..29a5aa1e7 --- /dev/null +++ b/packages/snjs/lib/Services/Api/Messages.ts @@ -0,0 +1,185 @@ +import { ProtocolVersion } from '@standardnotes/common' + +export const API_MESSAGE_GENERIC_INVALID_LOGIN = 'A server error occurred while trying to sign in. Please try again.' +export const API_MESSAGE_GENERIC_REGISTRATION_FAIL = + 'A server error occurred while trying to register. Please try again.' +export const API_MESSAGE_GENERIC_CHANGE_CREDENTIALS_FAIL = + 'Something went wrong while changing your credentials. Your credentials were not changed. Please try again.' +export const API_MESSAGE_GENERIC_SYNC_FAIL = 'Could not connect to server.' + +export const ServerErrorStrings = { + DeleteAccountError: 'Your account was unable to be deleted due to an error. Please try your request again.', +} + +export const API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL = 'Could not check your data integrity with the server.' + +export const API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL = 'Could not retrieve item.' + +export const API_MESSAGE_REGISTRATION_IN_PROGRESS = 'An existing registration request is already in progress.' +export const API_MESSAGE_LOGIN_IN_PROGRESS = 'An existing sign in request is already in progress.' +export const API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS = + 'An existing change credentials request is already in progress.' + +export const API_MESSAGE_FALLBACK_LOGIN_FAIL = 'Invalid email or password.' + +export const API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL = + 'A server error occurred while trying to refresh your session. Please try again.' + +export const API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS = + 'Your account session is being renewed with the server. Please try your request again.' + +export const API_MESSAGE_RATE_LIMITED = 'Too many successive server requests. Please wait a few minutes and try again.' + +export const API_MESSAGE_INVALID_SESSION = 'Please sign in to an account in order to continue with your request.' + +export const API_MESSAGE_FAILED_GET_SETTINGS = 'Failed to get settings.' +export const API_MESSAGE_FAILED_UPDATE_SETTINGS = 'Failed to update settings.' +export const API_MESSAGE_FAILED_LISTED_REGISTRATION = 'Unable to register for Listed. Please try again later.' + +export const API_MESSAGE_FAILED_CREATE_FILE_TOKEN = 'Failed to create file token.' + +export const API_MESSAGE_FAILED_SUBSCRIPTION_INFO = "Failed to get subscription's information." + +export const API_MESSAGE_FAILED_ACCESS_PURCHASE = 'Failed to access purchase flow.' + +export const API_MESSAGE_FAILED_DELETE_REVISION = 'Failed to delete revision.' + +export const API_MESSAGE_FAILED_OFFLINE_FEATURES = 'Failed to get offline features.' +export const API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING = `The extension you are attempting to install comes from an + untrusted source. Untrusted extensions may lower the security of your data. Do you want to continue?` +export const API_MESSAGE_FAILED_DOWNLOADING_EXTENSION = `Error downloading package details. Please check the + extension link and try again.` +export const API_MESSAGE_FAILED_OFFLINE_ACTIVATION = + 'An unknown issue occurred during offline activation. Please try again.' + +export const INVALID_EXTENSION_URL = 'Invalid extension URL.' + +export const UNSUPPORTED_PROTOCOL_VERSION = + 'This version of the application does not support your newer account type. Please upgrade to the latest version of Standard Notes to sign in.' + +export const EXPIRED_PROTOCOL_VERSION = + 'The protocol version associated with your account is outdated and no longer supported by this application. Please visit standardnotes.com/help/security for more information.' + +export const UNSUPPORTED_KEY_DERIVATION = + 'Your account was created on a platform with higher security capabilities than this browser supports. If we attempted to generate your login keys here, it would take hours. Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to log in.' + +export const INVALID_PASSWORD_COST = + 'Unable to sign in due to insecure password parameters. Please visit standardnotes.com/help/security for more information.' +export const INVALID_PASSWORD = 'Invalid password.' + +export const OUTDATED_PROTOCOL_ALERT_IGNORE = 'Sign In' +export const UPGRADING_ENCRYPTION = "Upgrading your account's encryption version…" + +export const SETTING_PASSCODE = 'Setting passcode…' +export const CHANGING_PASSCODE = 'Changing passcode…' +export const REMOVING_PASSCODE = 'Removing passcode…' + +export const DO_NOT_CLOSE_APPLICATION = 'Do not close the application until this process completes.' + +export const UNKNOWN_ERROR = 'Unknown error.' + +export function InsufficientPasswordMessage(minimum: number): string { + return `Your password must be at least ${minimum} characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.` +} + +export function StrictSignInFailed(current: ProtocolVersion, latest: ProtocolVersion): string { + return `Strict Sign In has refused the server's sign-in parameters. The latest account version is ${latest}, but the server is reporting a version of ${current} for your account. If you'd like to proceed with sign in anyway, please disable Strict Sign In and try again.` +} + +export const CredentialsChangeStrings = { + PasscodeRequired: 'Your passcode is required to process your credentials change.', + Failed: 'Unable to change your credentials due to a sync error. Please try again.', +} + +export const RegisterStrings = { + PasscodeRequired: 'Your passcode is required in order to register for an account.', +} + +export const SignInStrings = { + PasscodeRequired: 'Your passcode is required in order to sign in to your account.', + IncorrectMfa: 'Incorrect two-factor authentication code. Please try again.', + SignInCanceledMissingMfa: 'Your sign in request has been canceled.', +} + +export const ProtocolUpgradeStrings = { + SuccessAccount: + "Your encryption version has been successfully upgraded. You may be asked to enter your credentials again on other devices you're signed into.", + SuccessPasscodeOnly: 'Your encryption version has been successfully upgraded.', + Fail: 'Unable to upgrade encryption version. Please try again.', + UpgradingPasscode: 'Upgrading local encryption...', +} + +export const ChallengeModalTitle = { + Generic: 'Authentication Required', + Migration: 'Storage Update', +} + +export const SessionStrings = { + EnterEmailAndPassword: 'Please enter your account email and password.', + RecoverSession(email?: string): string { + return email + ? `Your credentials are needed for ${email} to refresh your session with the server.` + : 'Your credentials are needed to refresh your session with the server.' + }, + SessionRestored: 'Your session has been successfully restored.', + EnterMfa: 'Please enter your two-factor authentication code.', + MfaInputPlaceholder: 'Two-factor authentication code', + EmailInputPlaceholder: 'Email', + PasswordInputPlaceholder: 'Password', + KeychainRecoveryErrorTitle: 'Invalid Credentials', + KeychainRecoveryError: + 'The email or password you entered is incorrect.\n\nPlease note that this sign-in request is made against the default server. If you are using a custom server, you must uninstall the app then reinstall, and sign back into your account.', + RevokeTitle: 'Revoke this session?', + RevokeConfirmButton: 'Revoke', + RevokeCancelButton: 'Cancel', + RevokeText: + 'The associated app will be signed out and all data removed ' + + 'from the device when it is next launched. You can sign back in on that ' + + 'device at any time.', + CurrentSessionRevoked: 'Your session has been revoked and all local data has been removed ' + 'from this device.', +} + +export const ChallengeStrings = { + UnlockApplication: 'Authentication is required to unlock the application', + NoteAccess: 'Authentication is required to view this note', + FileAccess: 'Authentication is required to access this file', + ImportFile: 'Authentication is required to import a backup file', + AddPasscode: 'Authentication is required to add a passcode', + RemovePasscode: 'Authentication is required to remove your passcode', + ChangePasscode: 'Authentication is required to change your passcode', + ChangeAutolockInterval: 'Authentication is required to change autolock timer duration', + RevokeSession: 'Authentication is required to revoke a session', + EnterAccountPassword: 'Enter your account password', + EnterLocalPasscode: 'Enter your application passcode', + EnterPasscodeForMigration: + 'Your application passcode is required to perform an upgrade of your local data storage structure.', + EnterPasscodeForRootResave: 'Enter your application passcode to continue', + EnterCredentialsForProtocolUpgrade: 'Enter your credentials to perform encryption upgrade', + EnterCredentialsForDecryptedBackupDownload: 'Enter your credentials to download a decrypted backup', + AccountPasswordPlaceholder: 'Account Password', + LocalPasscodePlaceholder: 'Application Passcode', + DecryptEncryptedFile: 'Enter the account password associated with the import file', + ExportBackup: 'Authentication is required to export a backup', + DisableBiometrics: 'Authentication is required to disable biometrics', + UnprotectNote: 'Authentication is required to unprotect a note', + UnprotectFile: 'Authentication is required to unprotect a file', + SearchProtectedNotesText: 'Authentication is required to search protected contents', + SelectProtectedNote: 'Authentication is required to select a protected note', + DisableMfa: 'Authentication is required to disable two-factor authentication', + DeleteAccount: 'Authentication is required to delete your account', +} + +export const ErrorAlertStrings = { + MissingSessionTitle: 'Missing Session', + MissingSessionBody: + 'We were unable to load your server session. This represents an inconsistency with your application state. Please take an opportunity to backup your data, then sign out and sign back in to resolve this issue.', + + StorageDecryptErrorTitle: 'Storage Error', + StorageDecryptErrorBody: + "We were unable to decrypt your local storage. Please restart the app and try again. If you're unable to resolve this issue, and you have an account, you may try uninstalling the app then reinstalling, then signing back into your account. Otherwise, please contact help@standardnotes.org for support.", +} + +export const KeychainRecoveryStrings = { + Title: 'Restore Keychain', + Text: "We've detected that your keychain has been wiped. This can happen when restoring your device from a backup. Please enter your account password to restore your account keys.", +} diff --git a/packages/snjs/lib/Services/Api/Paths.ts b/packages/snjs/lib/Services/Api/Paths.ts new file mode 100644 index 000000000..07f273ac6 --- /dev/null +++ b/packages/snjs/lib/Services/Api/Paths.ts @@ -0,0 +1,74 @@ +import { Uuid } from '@standardnotes/common' +import { SettingName, SubscriptionSettingName } from '@standardnotes/settings' + +const FilesPaths = { + closeUploadSession: '/v1/files/upload/close-session', + createFileValetToken: '/v1/files/valet-tokens', + deleteFile: '/v1/files', + downloadFileChunk: '/v1/files', + startUploadSession: '/v1/files/upload/create-session', + uploadFileChunk: '/v1/files/upload/chunk', +} + +const UserPaths = { + changeCredentials: (userUuid: string) => `/v1/users/${userUuid}/attributes/credentials`, + deleteAccount: (userUuid: Uuid) => `/v1/users/${userUuid}`, + keyParams: '/v1/login-params', + refreshSession: '/v1/sessions/refresh', + register: '/v1/users', + session: (sessionUuid: string) => `/v1/sessions/${sessionUuid}`, + sessions: '/v1/sessions', + signIn: '/v1/login', + signOut: '/v1/logout', +} + +const ItemsPaths = { + checkIntegrity: '/v1/items/check-integrity', + getSingleItem: (uuid: Uuid) => `/v1/items/${uuid}`, + itemRevisions: (itemUuid: string) => `/v1/items/${itemUuid}/revisions`, + itemRevision: (itemUuid: string, revisionUuid: string) => `/v1/items/${itemUuid}/revisions/${revisionUuid}`, + sync: '/v1/items', +} + +const SettingsPaths = { + settings: (userUuid: Uuid) => `/v1/users/${userUuid}/settings`, + setting: (userUuid: Uuid, settingName: SettingName) => `/v1/users/${userUuid}/settings/${settingName}`, + subscriptionSetting: (userUuid: Uuid, settingName: SubscriptionSettingName) => + `/v1/users/${userUuid}/subscription-settings/${settingName}`, +} + +const SubscriptionPaths = { + offlineFeatures: '/v1/offline/features', + purchase: '/v1/purchase', + subscription: (userUuid: Uuid) => `/v1/users/${userUuid}/subscription`, + subscriptionTokens: '/v1/subscription-tokens', + userFeatures: (userUuid: string) => `/v1/users/${userUuid}/features`, +} + +const SubscriptionPathsV2 = { + subscriptions: '/v2/subscriptions', +} + +const UserPathsV2 = { + keyParams: '/v2/login-params', + signIn: '/v2/login', +} + +const ListedPaths = { + listedRegistration: (userUuid: Uuid) => `/v1/users/${userUuid}/integrations/listed`, +} + +export const Paths = { + v1: { + ...FilesPaths, + ...ItemsPaths, + ...ListedPaths, + ...SettingsPaths, + ...SubscriptionPaths, + ...UserPaths, + }, + v2: { + ...SubscriptionPathsV2, + ...UserPathsV2, + }, +} diff --git a/packages/snjs/lib/Services/Api/WebsocketsService.spec.ts b/packages/snjs/lib/Services/Api/WebsocketsService.spec.ts new file mode 100644 index 000000000..74f038951 --- /dev/null +++ b/packages/snjs/lib/Services/Api/WebsocketsService.spec.ts @@ -0,0 +1,30 @@ +import { InternalEventBusInterface } from '@standardnotes/services' +import { StorageKey, DiskStorageService } from '@Lib/index' +import { SNWebSocketsService } from './WebsocketsService' + +describe('webSocketsService', () => { + const webSocketUrl = '' + + let storageService: DiskStorageService + let internalEventBus: InternalEventBusInterface + + const createService = () => { + return new SNWebSocketsService(storageService, webSocketUrl, internalEventBus) + } + + beforeEach(() => { + storageService = {} as jest.Mocked + storageService.setValue = jest.fn() + + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + }) + + describe('setWebSocketUrl()', () => { + it('saves url in local storage', async () => { + const webSocketUrl = 'wss://test-websocket' + await createService().setWebSocketUrl(webSocketUrl) + expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.WebSocketUrl, webSocketUrl) + }) + }) +}) diff --git a/packages/snjs/lib/Services/Api/WebsocketsService.ts b/packages/snjs/lib/Services/Api/WebsocketsService.ts new file mode 100644 index 000000000..b4e230372 --- /dev/null +++ b/packages/snjs/lib/Services/Api/WebsocketsService.ts @@ -0,0 +1,67 @@ +import { UserRolesChangedEvent } from '@standardnotes/domain-events' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { AbstractService, InternalEventBusInterface, StorageKey } from '@standardnotes/services' + +export enum WebSocketsServiceEvent { + UserRoleMessageReceived = 'WebSocketMessageReceived', +} + +export class SNWebSocketsService extends AbstractService { + private webSocket?: WebSocket + + constructor( + private storageService: DiskStorageService, + private webSocketUrl: string | undefined, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public setWebSocketUrl(url: string | undefined): void { + this.webSocketUrl = url + this.storageService.setValue(StorageKey.WebSocketUrl, url) + } + + public loadWebSocketUrl(): void { + const storedValue = this.storageService.getValue(StorageKey.WebSocketUrl) + this.webSocketUrl = + storedValue || + this.webSocketUrl || + ( + window as { + _websocket_url?: string + } + )._websocket_url + } + + public startWebSocketConnection(authToken: string): void { + if (this.webSocketUrl) { + try { + this.webSocket = new WebSocket(`${this.webSocketUrl}?authToken=Bearer+${authToken}`) + this.webSocket.onmessage = this.onWebSocketMessage.bind(this) + this.webSocket.onclose = this.onWebSocketClose.bind(this) + } catch (e) { + console.error('Error starting WebSocket connection', e) + } + } + } + + public closeWebSocketConnection(): void { + this.webSocket?.close() + } + + private onWebSocketMessage(event: MessageEvent) { + const eventData: UserRolesChangedEvent = JSON.parse(event.data) + void this.notifyEvent(WebSocketsServiceEvent.UserRoleMessageReceived, eventData) + } + + private onWebSocketClose() { + this.webSocket = undefined + } + + override deinit(): void { + super.deinit() + ;(this.storageService as unknown) = undefined + this.closeWebSocketConnection() + } +} diff --git a/packages/snjs/lib/Services/Api/index.ts b/packages/snjs/lib/Services/Api/index.ts new file mode 100644 index 000000000..88e50bbd5 --- /dev/null +++ b/packages/snjs/lib/Services/Api/index.ts @@ -0,0 +1,7 @@ +export * from './ApiService' +export * from './HttpService' +export * from './Messages' +export * from './Paths' +export * from '../Session/Sessions/Session' +export * from '../Session/SessionManager' +export * from './WebsocketsService' diff --git a/packages/snjs/lib/Services/AppService/ApplicationService.ts b/packages/snjs/lib/Services/AppService/ApplicationService.ts new file mode 100644 index 000000000..d11dd51db --- /dev/null +++ b/packages/snjs/lib/Services/AppService/ApplicationService.ts @@ -0,0 +1,75 @@ +import { ApplicationEvent } from '@Lib/Application/Event' +import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' +import { SNApplication } from '../../Application/Application' + +export class ApplicationService extends AbstractService { + private unsubApp!: () => void + + constructor(protected application: SNApplication, protected override internalEventBus: InternalEventBusInterface) { + super(internalEventBus) + this.addAppEventObserverAfterSubclassesFinishConstructing() + } + + override deinit() { + ;(this.application as unknown) = undefined + + this.unsubApp() + ;(this.unsubApp as unknown) = undefined + + super.deinit() + } + + addAppEventObserverAfterSubclassesFinishConstructing() { + setTimeout(() => { + this.addAppEventObserver() + }, 0) + } + + addAppEventObserver() { + if (this.application.isStarted()) { + void this.onAppStart() + } + if (this.application.isLaunched()) { + void this.onAppLaunch() + } + + this.unsubApp = this.application.addEventObserver(async (event: ApplicationEvent) => { + await this.onAppEvent(event) + if (event === ApplicationEvent.Started) { + void this.onAppStart() + } else if (event === ApplicationEvent.Launched) { + void this.onAppLaunch() + } else if (event === ApplicationEvent.CompletedFullSync) { + this.onAppFullSync() + } else if (event === ApplicationEvent.CompletedIncrementalSync) { + this.onAppIncrementalSync() + } else if (event === ApplicationEvent.KeyStatusChanged) { + void this.onAppKeyChange() + } + }) + } + + async onAppEvent(_event: ApplicationEvent) { + /** Optional override */ + } + + async onAppStart() { + /** Optional override */ + } + + async onAppLaunch() { + /** Optional override */ + } + + async onAppKeyChange() { + /** Optional override */ + } + + onAppIncrementalSync() { + /** Optional override */ + } + + onAppFullSync() { + /** Optional override */ + } +} diff --git a/packages/snjs/lib/Services/Challenge/Challenge.ts b/packages/snjs/lib/Services/Challenge/Challenge.ts new file mode 100644 index 000000000..c0ff9bf7d --- /dev/null +++ b/packages/snjs/lib/Services/Challenge/Challenge.ts @@ -0,0 +1,112 @@ +import { ChallengeModalTitle, ChallengeStrings } from '../Api/Messages' +import { assertUnreachable } from '@standardnotes/utils' +import { ChallengeValidation, ChallengeReason, ChallengeInterface, ChallengePrompt } from '@standardnotes/services' + +/** + * A challenge is a stateless description of what the client needs to provide + * in order to proceed. + */ +export class Challenge implements ChallengeInterface { + public readonly id = Math.random() + + constructor( + public readonly prompts: ChallengePrompt[], + public readonly reason: ChallengeReason, + public readonly cancelable: boolean, + public readonly _heading?: string, + public readonly _subheading?: string, + ) { + Object.freeze(this) + } + + /** Outside of the modal, this is the title of the modal itself */ + get modalTitle(): string { + switch (this.reason) { + case ChallengeReason.Migration: + return ChallengeModalTitle.Migration + default: + return ChallengeModalTitle.Generic + } + } + + /** Inside of the modal, this is the H1 */ + get heading(): string | undefined { + if (this._heading) { + return this._heading + } else { + switch (this.reason) { + case ChallengeReason.ApplicationUnlock: + return ChallengeStrings.UnlockApplication + case ChallengeReason.Migration: + return ChallengeStrings.EnterLocalPasscode + case ChallengeReason.ResaveRootKey: + return ChallengeStrings.EnterPasscodeForRootResave + case ChallengeReason.ProtocolUpgrade: + return ChallengeStrings.EnterCredentialsForProtocolUpgrade + case ChallengeReason.AccessProtectedNote: + return ChallengeStrings.NoteAccess + case ChallengeReason.AccessProtectedFile: + return ChallengeStrings.FileAccess + case ChallengeReason.ImportFile: + return ChallengeStrings.ImportFile + case ChallengeReason.AddPasscode: + return ChallengeStrings.AddPasscode + case ChallengeReason.RemovePasscode: + return ChallengeStrings.RemovePasscode + case ChallengeReason.ChangePasscode: + return ChallengeStrings.ChangePasscode + case ChallengeReason.ChangeAutolockInterval: + return ChallengeStrings.ChangeAutolockInterval + case ChallengeReason.CreateDecryptedBackupWithProtectedItems: + return ChallengeStrings.EnterCredentialsForDecryptedBackupDownload + case ChallengeReason.RevokeSession: + return ChallengeStrings.RevokeSession + case ChallengeReason.DecryptEncryptedFile: + return ChallengeStrings.DecryptEncryptedFile + case ChallengeReason.ExportBackup: + return ChallengeStrings.ExportBackup + case ChallengeReason.DisableBiometrics: + return ChallengeStrings.DisableBiometrics + case ChallengeReason.UnprotectNote: + return ChallengeStrings.UnprotectNote + case ChallengeReason.UnprotectFile: + return ChallengeStrings.UnprotectFile + case ChallengeReason.SearchProtectedNotesText: + return ChallengeStrings.SearchProtectedNotesText + case ChallengeReason.SelectProtectedNote: + return ChallengeStrings.SelectProtectedNote + case ChallengeReason.DisableMfa: + return ChallengeStrings.DisableMfa + case ChallengeReason.DeleteAccount: + return ChallengeStrings.DeleteAccount + case ChallengeReason.Custom: + return '' + default: + return assertUnreachable(this.reason) + } + } + } + + /** Inside of the modal, this is the H2 */ + get subheading(): string | undefined { + if (this._subheading) { + return this._subheading + } + + switch (this.reason) { + case ChallengeReason.Migration: + return ChallengeStrings.EnterPasscodeForMigration + default: + return undefined + } + } + + hasPromptForValidationType(type: ChallengeValidation): boolean { + for (const prompt of this.prompts) { + if (prompt.validation === type) { + return true + } + } + return false + } +} diff --git a/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts b/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts new file mode 100644 index 000000000..63a182152 --- /dev/null +++ b/packages/snjs/lib/Services/Challenge/ChallengeOperation.ts @@ -0,0 +1,108 @@ +import { Challenge } from './Challenge' +import { ChallengeResponse } from './ChallengeResponse' +import { removeFromArray } from '@standardnotes/utils' +import { ValueCallback } from './ChallengeService' +import { ChallengeValue, ChallengeArtifacts } from '@standardnotes/services' + +/** + * A challenge operation stores user-submitted values and callbacks. + * When its values are updated, it will trigger the associated callbacks (valid/invalid/complete) + */ +export class ChallengeOperation { + private nonvalidatedValues: ChallengeValue[] = [] + private validValues: ChallengeValue[] = [] + private invalidValues: ChallengeValue[] = [] + private artifacts: ChallengeArtifacts = {} + + constructor( + public challenge: Challenge, + public onValidValue: ValueCallback, + public onInvalidValue: ValueCallback, + public onNonvalidatedSubmit: (response: ChallengeResponse) => void, + public onComplete: (response: ChallengeResponse) => void, + public onCancel: () => void, + ) {} + + deinit() { + ;(this.challenge as unknown) = undefined + ;(this.onValidValue as unknown) = undefined + ;(this.onInvalidValue as unknown) = undefined + ;(this.onNonvalidatedSubmit as unknown) = undefined + ;(this.onComplete as unknown) = undefined + ;(this.onCancel as unknown) = undefined + ;(this.nonvalidatedValues as unknown) = undefined + ;(this.validValues as unknown) = undefined + ;(this.invalidValues as unknown) = undefined + ;(this.artifacts as unknown) = undefined + } + + /** + * Mark this challenge as complete, triggering the resolve function, + * as well as notifying the client + */ + public complete(response?: ChallengeResponse) { + if (!response) { + response = new ChallengeResponse(this.challenge, this.validValues, this.artifacts) + } + this.onComplete?.(response) + } + + public nonvalidatedSubmit() { + const response = new ChallengeResponse(this.challenge, this.nonvalidatedValues.slice(), this.artifacts) + this.onNonvalidatedSubmit?.(response) + /** Reset values */ + this.nonvalidatedValues = [] + } + + public cancel() { + this.onCancel?.() + } + + /** + * @returns Returns true if the challenge has received all valid responses + */ + public isFinished() { + return this.validValues.length === this.challenge.prompts.length + } + + private nonvalidatedPrompts() { + return this.challenge.prompts.filter((p) => !p.validates) + } + + public addNonvalidatedValue(value: ChallengeValue) { + const valuesArray = this.nonvalidatedValues + const matching = valuesArray.find((v) => v.prompt.id === value.prompt.id) + if (matching) { + removeFromArray(valuesArray, matching) + } + valuesArray.push(value) + if (this.nonvalidatedValues.length === this.nonvalidatedPrompts().length) { + this.nonvalidatedSubmit() + } + } + + /** + * Sets the values validation status, as well as handles subsequent actions, + * such as completing the operation if all valid values are supplied, as well as + * notifying the client of this new value's validation status. + */ + public setValueStatus(value: ChallengeValue, valid: boolean, artifacts?: ChallengeArtifacts) { + const valuesArray = valid ? this.validValues : this.invalidValues + const matching = valuesArray.find((v) => v.prompt.validation === value.prompt.validation) + if (matching) { + removeFromArray(valuesArray, matching) + } + valuesArray.push(value) + Object.assign(this.artifacts, artifacts) + + if (this.isFinished()) { + this.complete() + } else { + if (valid) { + this.onValidValue?.(value) + } else { + this.onInvalidValue?.(value) + } + } + } +} diff --git a/packages/snjs/lib/Services/Challenge/ChallengeResponse.ts b/packages/snjs/lib/Services/Challenge/ChallengeResponse.ts new file mode 100644 index 000000000..6ac661c36 --- /dev/null +++ b/packages/snjs/lib/Services/Challenge/ChallengeResponse.ts @@ -0,0 +1,33 @@ +import { isNullOrUndefined } from '@standardnotes/utils' +import { Challenge } from './Challenge' +import { + ChallengeResponseInterface, + ChallengeValidation, + ChallengeValue, + ChallengeArtifacts, +} from '@standardnotes/services' + +export class ChallengeResponse implements ChallengeResponseInterface { + constructor( + public readonly challenge: Challenge, + public readonly values: ChallengeValue[], + public readonly artifacts?: ChallengeArtifacts, + ) { + Object.freeze(this) + } + + getValueForType(type: ChallengeValidation): ChallengeValue { + const value = this.values.find((value) => value.prompt.validation === type) + if (isNullOrUndefined(value)) { + throw Error('Could not find value for validation type ' + type) + } + return value + } + + getDefaultValue(): ChallengeValue { + if (this.values.length > 1) { + throw Error('Attempting to retrieve default response value when more than one value exists') + } + return this.values[0] + } +} diff --git a/packages/snjs/lib/Services/Challenge/ChallengeService.ts b/packages/snjs/lib/Services/Challenge/ChallengeService.ts new file mode 100644 index 000000000..7783143b2 --- /dev/null +++ b/packages/snjs/lib/Services/Challenge/ChallengeService.ts @@ -0,0 +1,300 @@ +import { RootKeyInterface } from '@standardnotes/models' +import { EncryptionService } from '@standardnotes/encryption' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { removeFromArray } from '@standardnotes/utils' +import { isValidProtectionSessionLength } from '../Protection/ProtectionService' +import { + AbstractService, + ChallengeServiceInterface, + InternalEventBusInterface, + ChallengeArtifacts, + ChallengeReason, + ChallengeValidation, + ChallengeValue, + ChallengeInterface, + ChallengePromptInterface, + ChallengePrompt, +} from '@standardnotes/services' +import { ChallengeResponse } from './ChallengeResponse' +import { ChallengeOperation } from './ChallengeOperation' +import { Challenge } from './Challenge' + +type ChallengeValidationResponse = { + valid: boolean + artifacts?: ChallengeArtifacts +} + +export type ValueCallback = (value: ChallengeValue) => void + +export type ChallengeObserver = { + onValidValue?: ValueCallback + onInvalidValue?: ValueCallback + onNonvalidatedSubmit?: (response: ChallengeResponse) => void + onComplete?: (response: ChallengeResponse) => void + onCancel?: () => void +} + +const clearChallengeObserver = (observer: ChallengeObserver) => { + observer.onCancel = undefined + observer.onComplete = undefined + observer.onValidValue = undefined + observer.onInvalidValue = undefined + observer.onNonvalidatedSubmit = undefined +} + +/** + * The challenge service creates, updates and keeps track of running challenge operations. + */ +export class ChallengeService extends AbstractService implements ChallengeServiceInterface { + private challengeOperations: Record = {} + public sendChallenge!: (challenge: Challenge) => void + private challengeObservers: Record = {} + + constructor( + private storageService: DiskStorageService, + private protocolService: EncryptionService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public override deinit() { + ;(this.storageService as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.sendChallenge as unknown) = undefined + ;(this.challengeOperations as unknown) = undefined + ;(this.challengeObservers as unknown) = undefined + super.deinit() + } + + public promptForChallengeResponse(challenge: Challenge): Promise { + return new Promise((resolve) => { + this.createOrGetChallengeOperation(challenge, resolve) + this.sendChallenge(challenge) + }) + } + + public createChallenge( + prompts: ChallengePromptInterface[], + reason: ChallengeReason, + cancelable: boolean, + heading?: string, + subheading?: string, + ): ChallengeInterface { + return new Challenge(prompts, reason, cancelable, heading, subheading) + } + + public async validateChallengeValue(value: ChallengeValue): Promise { + switch (value.prompt.validation) { + case ChallengeValidation.LocalPasscode: + return this.protocolService.validatePasscode(value.value as string) + case ChallengeValidation.AccountPassword: + return this.protocolService.validateAccountPassword(value.value as string) + case ChallengeValidation.Biometric: + return { valid: value.value === true } + case ChallengeValidation.ProtectionSessionDuration: + return { valid: isValidProtectionSessionLength(value.value) } + default: + throw Error(`Unhandled validation mode ${value.prompt.validation}`) + } + } + + public async promptForCorrectPasscode(reason: ChallengeReason): Promise { + const challenge = new Challenge([new ChallengePrompt(ChallengeValidation.LocalPasscode)], reason, true) + const response = await this.promptForChallengeResponse(challenge) + if (!response) { + return undefined + } + const value = response.getValueForType(ChallengeValidation.LocalPasscode) + return value.value as string + } + + /** + * Returns the wrapping key for operations that require resaving the root key + * (changing the account password, signing in, registering, or upgrading protocol) + * Returns empty object if no passcode is configured. + * Otherwise returns {cancled: true} if the operation is canceled, or + * {wrappingKey} with the result. + * @param passcode - If the consumer already has access to the passcode, + * they can pass it here so that the user is not prompted again. + */ + async getWrappingKeyIfApplicable(passcode?: string): Promise< + | { + canceled?: undefined + wrappingKey?: undefined + } + | { + canceled: boolean + wrappingKey?: undefined + } + | { + wrappingKey: RootKeyInterface + canceled?: undefined + } + > { + if (!this.protocolService.hasPasscode()) { + return {} + } + + if (!passcode) { + passcode = await this.promptForCorrectPasscode(ChallengeReason.ResaveRootKey) + if (!passcode) { + return { canceled: true } + } + } + + const wrappingKey = await this.protocolService.computeWrappingKey(passcode) + return { wrappingKey } + } + + public isPasscodeLocked() { + return this.protocolService.isPasscodeLocked() + } + + public addChallengeObserver(challenge: Challenge, observer: ChallengeObserver) { + const observers = this.challengeObservers[challenge.id] || [] + + observers.push(observer) + + this.challengeObservers[challenge.id] = observers + + return () => { + clearChallengeObserver(observer) + + removeFromArray(observers, observer) + } + } + + private createOrGetChallengeOperation( + challenge: Challenge, + resolve: (response: ChallengeResponse | undefined) => void, + ): ChallengeOperation { + let operation = this.getChallengeOperation(challenge) + + if (!operation) { + operation = new ChallengeOperation( + challenge, + (value: ChallengeValue) => { + this.onChallengeValidValue(challenge, value) + }, + (value: ChallengeValue) => { + this.onChallengeInvalidValue(challenge, value) + }, + (response: ChallengeResponse) => { + this.onChallengeNonvalidatedSubmit(challenge, response) + resolve(response) + }, + (response: ChallengeResponse) => { + this.onChallengeComplete(challenge, response) + resolve(response) + }, + () => { + this.onChallengeCancel(challenge) + resolve(undefined) + }, + ) + + this.challengeOperations[challenge.id] = operation + } + return operation + } + + private performOnObservers(challenge: Challenge, perform: (observer: ChallengeObserver) => void) { + const observers = this.challengeObservers[challenge.id] || [] + + for (const observer of observers) { + perform(observer) + } + } + + private onChallengeValidValue(challenge: Challenge, value: ChallengeValue) { + this.performOnObservers(challenge, (observer) => { + observer.onValidValue?.(value) + }) + } + + private onChallengeInvalidValue(challenge: Challenge, value: ChallengeValue) { + this.performOnObservers(challenge, (observer) => { + observer.onInvalidValue?.(value) + }) + } + + private onChallengeNonvalidatedSubmit(challenge: Challenge, response: ChallengeResponse) { + this.performOnObservers(challenge, (observer) => { + observer.onNonvalidatedSubmit?.(response) + }) + } + + private onChallengeComplete(challenge: Challenge, response: ChallengeResponse) { + this.performOnObservers(challenge, (observer) => { + observer.onComplete?.(response) + }) + } + + private onChallengeCancel(challenge: Challenge) { + this.performOnObservers(challenge, (observer) => { + observer.onCancel?.() + }) + } + + private getChallengeOperation(challenge: Challenge) { + return this.challengeOperations[challenge.id] + } + + private deleteChallengeOperation(operation: ChallengeOperation) { + const challenge = operation.challenge + operation.deinit() + + delete this.challengeOperations[challenge.id] + } + + public cancelChallenge(challenge: Challenge) { + const operation = this.challengeOperations[challenge.id] + operation.cancel() + + this.deleteChallengeOperation(operation) + } + + public completeChallenge(challenge: Challenge): void { + const operation = this.challengeOperations[challenge.id] + operation.complete() + + this.deleteChallengeOperation(operation) + } + + public async submitValuesForChallenge(challenge: Challenge, values: ChallengeValue[]) { + if (values.length === 0) { + throw Error('Attempting to submit 0 values for challenge') + } + + for (const value of values) { + if (!value.prompt.validates) { + const operation = this.getChallengeOperation(challenge) + operation.addNonvalidatedValue(value) + } else { + const { valid, artifacts } = await this.validateChallengeValue(value) + this.setValidationStatusForChallenge(challenge, value, valid, artifacts) + } + } + } + + public setValidationStatusForChallenge( + challenge: Challenge, + value: ChallengeValue, + valid: boolean, + artifacts?: ChallengeArtifacts, + ) { + const operation = this.getChallengeOperation(challenge) + operation.setValueStatus(value, valid, artifacts) + + if (operation.isFinished()) { + this.deleteChallengeOperation(operation) + + const observers = this.challengeObservers[challenge.id] + observers.forEach(clearChallengeObserver) + observers.length = 0 + + delete this.challengeObservers[challenge.id] + } + } +} diff --git a/packages/snjs/lib/Services/Challenge/index.ts b/packages/snjs/lib/Services/Challenge/index.ts new file mode 100644 index 000000000..55476a23c --- /dev/null +++ b/packages/snjs/lib/Services/Challenge/index.ts @@ -0,0 +1,4 @@ +export * from './Challenge' +export * from './ChallengeOperation' +export * from './ChallengeResponse' +export * from './ChallengeService' diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts new file mode 100644 index 000000000..faf597bf4 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.spec.ts @@ -0,0 +1,368 @@ +/** + * @jest-environment jsdom + */ + +import { SNPreferencesService } from '../Preferences/PreferencesService' +import { + ComponentAction, + ComponentPermission, + FeatureDescription, + FindNativeFeature, + FeatureIdentifier, +} from '@standardnotes/features' +import { DesktopManagerInterface } from '@Lib/Services/ComponentManager/Types' +import { ContentType } from '@standardnotes/common' +import { GenericItem, SNComponent } from '@standardnotes/models' +import { InternalEventBusInterface, Environment, Platform, AlertService } from '@standardnotes/services' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' +import { SNComponentManager } from './ComponentManager' +import { SNSyncService } from '../Sync/SyncService' + +describe('featuresService', () => { + let itemManager: ItemManager + let featureService: SNFeaturesService + let alertService: AlertService + let syncService: SNSyncService + let prefsService: SNPreferencesService + let internalEventBus: InternalEventBusInterface + + const desktopExtHost = 'http://localhost:123' + + const createManager = (environment: Environment, platform: Platform) => { + const desktopManager: DesktopManagerInterface = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + syncComponentsInstallation() {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + registerUpdateObserver() {}, + getExtServerHost() { + return desktopExtHost + }, + } + + const manager = new SNComponentManager( + itemManager, + syncService, + featureService, + prefsService, + alertService, + environment, + platform, + internalEventBus, + ) + manager.setDesktopManager(desktopManager) + return manager + } + + beforeEach(() => { + syncService = {} as jest.Mocked + syncService.sync = jest.fn() + + itemManager = {} as jest.Mocked + itemManager.getItems = jest.fn().mockReturnValue([]) + itemManager.createItem = jest.fn() + itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + itemManager.setItemsToBeDeleted = jest.fn() + itemManager.addObserver = jest.fn() + itemManager.changeItem = jest.fn() + itemManager.changeFeatureRepo = jest.fn() + + featureService = {} as jest.Mocked + + prefsService = {} as jest.Mocked + + alertService = {} as jest.Mocked + alertService.confirm = jest.fn() + alertService.alert = jest.fn() + + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + }) + + const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => { + return new SNComponent({ + uuid: '789', + content_type: ContentType.Component, + content: { + package_info: { + hosted_url: 'https://example.com/component', + identifier: identifier || FeatureIdentifier.PlusEditor, + file_type: file_type ?? 'html', + valid_until: new Date(), + }, + }, + } as never) + } + + const deprecatedComponent = () => { + return new SNComponent({ + uuid: '789', + content_type: ContentType.Component, + content: { + package_info: { + hosted_url: 'https://example.com/component', + identifier: FeatureIdentifier.DeprecatedFileSafe, + valid_until: new Date(), + }, + }, + } as never) + } + + const thirdPartyComponent = () => { + return new SNComponent({ + uuid: '789', + content_type: ContentType.Component, + content: { + local_url: 'sn://Extensions/non-native-identifier/dist/index.html', + hosted_url: 'https://example.com/component', + package_info: { + identifier: 'non-native-identifier', + valid_until: new Date(), + }, + }, + } as never) + } + + describe('permissions', () => { + it('editor should be able to to stream single note', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamContextItem, + content_types: [ContentType.Note], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect( + manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.MarkdownVisualEditor), permissions), + ).toEqual(true) + }) + + it('no extension should be able to stream multiple notes', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ContentType.Note], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + }) + + it('no extension should be able to stream multiple tags', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ContentType.Tag], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + }) + + it('no extension should be able to stream multiple notes or tags', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ContentType.Tag, ContentType.Note], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect(manager.areRequestedPermissionsValid(nativeComponent(), permissions)).toEqual(false) + }) + + it('some valid and some invalid permissions should still return invalid permissions', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ContentType.Tag, ContentType.FilesafeFileMetadata], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect( + manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions), + ).toEqual(false) + }) + + it('filesafe should be able to stream its files', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ + ContentType.FilesafeFileMetadata, + ContentType.FilesafeCredentials, + ContentType.FilesafeIntegration, + ], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect( + manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedFileSafe), permissions), + ).toEqual(true) + }) + + it('bold editor should be able to stream filesafe files', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ + ContentType.FilesafeFileMetadata, + ContentType.FilesafeCredentials, + ContentType.FilesafeIntegration, + ], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect( + manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.DeprecatedBoldEditor), permissions), + ).toEqual(true) + }) + + it('non bold editor should not able to stream filesafe files', () => { + const permissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: [ + ContentType.FilesafeFileMetadata, + ContentType.FilesafeCredentials, + ContentType.FilesafeIntegration, + ], + }, + ] + + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + expect(manager.areRequestedPermissionsValid(nativeComponent(FeatureIdentifier.PlusEditor), permissions)).toEqual( + false, + ) + }) + }) + + describe('urlForComponent', () => { + describe('desktop', () => { + it('returns native path for native component', () => { + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + const component = nativeComponent() + const url = manager.urlForComponent(component) + const feature = FindNativeFeature(component.identifier) + expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`) + }) + + it('returns native path for deprecated native component', () => { + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + const component = deprecatedComponent() + const url = manager.urlForComponent(component) + const feature = FindNativeFeature(component.identifier) + expect(url).toEqual(`${desktopExtHost}/components/${feature?.identifier}/${feature?.index_path}`) + }) + + it('returns nonnative path for third party component', () => { + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + const component = thirdPartyComponent() + const url = manager.urlForComponent(component) + expect(url).toEqual(`${desktopExtHost}/Extensions/${component.identifier}/dist/index.html`) + }) + + it('returns hosted url for third party component with no local_url', () => { + const manager = createManager(Environment.Desktop, Platform.MacDesktop) + const component = new SNComponent({ + uuid: '789', + content_type: ContentType.Component, + content: { + hosted_url: 'https://example.com/component', + package_info: { + identifier: 'non-native-identifier', + valid_until: new Date(), + }, + }, + } as never) + const url = manager.urlForComponent(component) + expect(url).toEqual('https://example.com/component') + }) + }) + + describe('web', () => { + it('returns native path for native component', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const component = nativeComponent() + const url = manager.urlForComponent(component) + const feature = FindNativeFeature(component.identifier) as FeatureDescription + expect(url).toEqual(`http://localhost/components/assets/${component.identifier}/${feature.index_path}`) + }) + + it('returns hosted path for third party component', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const component = thirdPartyComponent() + const url = manager.urlForComponent(component) + expect(url).toEqual(component.hosted_url) + }) + }) + }) + + describe('editor change alert', () => { + it('should not require alert switching from plain editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const component = nativeComponent() + const requiresAlert = manager.doesEditorChangeRequireAlert(undefined, component) + expect(requiresAlert).toBe(false) + }) + + it('should not require alert switching to plain editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const component = nativeComponent() + const requiresAlert = manager.doesEditorChangeRequireAlert(component, undefined) + expect(requiresAlert).toBe(false) + }) + + it('should not require alert switching from a markdown editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const htmlEditor = nativeComponent() + const markdownEditor = nativeComponent(undefined, 'md') + const requiresAlert = manager.doesEditorChangeRequireAlert(markdownEditor, htmlEditor) + expect(requiresAlert).toBe(false) + }) + + it('should not require alert switching to a markdown editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const htmlEditor = nativeComponent() + const markdownEditor = nativeComponent(undefined, 'md') + const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, markdownEditor) + expect(requiresAlert).toBe(false) + }) + + it('should not require alert switching from & to a html editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const htmlEditor = nativeComponent() + const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, htmlEditor) + expect(requiresAlert).toBe(false) + }) + + it('should require alert switching from a html editor to custom editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const htmlEditor = nativeComponent() + const customEditor = nativeComponent(undefined, 'json') + const requiresAlert = manager.doesEditorChangeRequireAlert(htmlEditor, customEditor) + expect(requiresAlert).toBe(true) + }) + + it('should require alert switching from a custom editor to html editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const htmlEditor = nativeComponent() + const customEditor = nativeComponent(undefined, 'json') + const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, htmlEditor) + expect(requiresAlert).toBe(true) + }) + + it('should require alert switching from a custom editor to custom editor', () => { + const manager = createManager(Environment.Web, Platform.MacWeb) + const customEditor = nativeComponent(undefined, 'json') + const requiresAlert = manager.doesEditorChangeRequireAlert(customEditor, customEditor) + expect(requiresAlert).toBe(true) + }) + }) +}) diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts new file mode 100644 index 000000000..acf6bf2b4 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentManager.ts @@ -0,0 +1,673 @@ +import { AllowedBatchStreaming } from './Types' +import { SNPreferencesService } from '../Preferences/PreferencesService' +import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService' +import { ContentType, DisplayStringForContentType } from '@standardnotes/common' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { SNNote, SNTheme, SNComponent, ComponentMutator, PayloadEmitSource } from '@standardnotes/models' +import { SNSyncService } from '@Lib/Services/Sync/SyncService' +import find from 'lodash/find' +import uniq from 'lodash/uniq' +import { ComponentArea, ComponentAction, ComponentPermission, FindNativeFeature } from '@standardnotes/features' +import { Copy, filterFromArray, removeFromArray, sleep, assert } from '@standardnotes/utils' +import { UuidString } from '@Lib/Types/UuidString' +import { + PermissionDialog, + DesktopManagerInterface, + AllowedBatchContentTypes, +} from '@Lib/Services/ComponentManager/Types' +import { ActionObserver, ComponentViewer } from '@Lib/Services/ComponentManager/ComponentViewer' +import { + AbstractService, + InternalEventBusInterface, + Environment, + Platform, + AlertService, +} from '@standardnotes/services' + +const DESKTOP_URL_PREFIX = 'sn://' +const LOCAL_HOST = 'localhost' +const CUSTOM_LOCAL_HOST = 'sn.local' +const ANDROID_LOCAL_HOST = '10.0.2.2' + +declare global { + interface Window { + /** IE Handlers */ + attachEvent(event: string, listener: EventListener): boolean + detachEvent(event: string, listener: EventListener): void + } +} + +export enum ComponentManagerEvent { + ViewerDidFocus = 'ViewerDidFocus', +} + +export type EventData = { + componentViewer?: ComponentViewer +} + +/** + * Responsible for orchestrating component functionality, including editors, themes, + * and other components. The component manager primarily deals with iframes, and orchestrates + * sending and receiving messages to and from frames via the postMessage API. + */ +export class SNComponentManager extends AbstractService { + private desktopManager?: DesktopManagerInterface + private viewers: ComponentViewer[] = [] + private removeItemObserver!: () => void + private permissionDialogs: PermissionDialog[] = [] + + constructor( + private itemManager: ItemManager, + private syncService: SNSyncService, + private featuresService: SNFeaturesService, + private preferencesSerivce: SNPreferencesService, + protected alertService: AlertService, + private environment: Environment, + private platform: Platform, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + this.loggingEnabled = false + + this.addItemObserver() + + /* On mobile, events listeners are handled by a respective component */ + if (environment !== Environment.Mobile) { + window.addEventListener + ? window.addEventListener('focus', this.detectFocusChange, true) + : window.attachEvent('onfocusout', this.detectFocusChange) + window.addEventListener + ? window.addEventListener('blur', this.detectFocusChange, true) + : window.attachEvent('onblur', this.detectFocusChange) + + window.addEventListener('message', this.onWindowMessage, true) + } + } + + get isDesktop(): boolean { + return this.environment === Environment.Desktop + } + + get isMobile(): boolean { + return this.environment === Environment.Mobile + } + + get components(): SNComponent[] { + return this.itemManager.getDisplayableComponents() + } + + componentsForArea(area: ComponentArea): SNComponent[] { + return this.components.filter((component) => { + return component.area === area + }) + } + + override deinit(): void { + super.deinit() + + for (const viewer of this.viewers) { + viewer.destroy() + } + + this.viewers.length = 0 + this.permissionDialogs.length = 0 + + this.desktopManager = undefined + ;(this.itemManager as unknown) = undefined + ;(this.featuresService as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.preferencesSerivce as unknown) = undefined + + this.removeItemObserver?.() + ;(this.removeItemObserver as unknown) = undefined + + if (window && !this.isMobile) { + window.removeEventListener('focus', this.detectFocusChange, true) + window.removeEventListener('blur', this.detectFocusChange, true) + window.removeEventListener('message', this.onWindowMessage, true) + } + + ;(this.detectFocusChange as unknown) = undefined + ;(this.onWindowMessage as unknown) = undefined + } + + public createComponentViewer( + component: SNComponent, + contextItem?: UuidString, + actionObserver?: ActionObserver, + urlOverride?: string, + ): ComponentViewer { + const viewer = new ComponentViewer( + component, + this.itemManager, + this.syncService, + this.alertService, + this.preferencesSerivce, + this.featuresService, + this.environment, + this.platform, + { + runWithPermissions: this.runWithPermissions.bind(this), + urlsForActiveThemes: this.urlsForActiveThemes.bind(this), + }, + urlOverride || this.urlForComponent(component), + contextItem, + actionObserver, + ) + this.viewers.push(viewer) + return viewer + } + + public destroyComponentViewer(viewer: ComponentViewer): void { + viewer.destroy() + removeFromArray(this.viewers, viewer) + } + + setDesktopManager(desktopManager: DesktopManagerInterface): void { + this.desktopManager = desktopManager + this.configureForDesktop() + } + + handleChangedComponents(components: SNComponent[], source: PayloadEmitSource): void { + const acceptableSources = [ + PayloadEmitSource.LocalChanged, + PayloadEmitSource.RemoteRetrieved, + PayloadEmitSource.LocalDatabaseLoaded, + PayloadEmitSource.LocalInserted, + ] + + if (components.length === 0 || !acceptableSources.includes(source)) { + return + } + + if (this.isDesktop) { + const thirdPartyComponents = components.filter((component) => { + const nativeFeature = FindNativeFeature(component.identifier) + return nativeFeature ? false : true + }) + if (thirdPartyComponents.length > 0) { + this.desktopManager?.syncComponentsInstallation(thirdPartyComponents) + } + } + + const themes = components.filter((c) => c.isTheme()) + if (themes.length > 0) { + this.postActiveThemesToAllViewers() + } + } + + addItemObserver(): void { + this.removeItemObserver = this.itemManager.addObserver( + [ContentType.Component, ContentType.Theme], + ({ changed, inserted, source }) => { + const items = [...changed, ...inserted] + this.handleChangedComponents(items, source) + }, + ) + } + + detectFocusChange = (): void => { + const activeIframes = this.allComponentIframes() + for (const iframe of activeIframes) { + if (document.activeElement === iframe) { + setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const viewer = this.findComponentViewer( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + iframe.dataset.componentViewerId!, + )! + void this.notifyEvent(ComponentManagerEvent.ViewerDidFocus, { + componentViewer: viewer, + }) + }) + return + } + } + } + + onWindowMessage = (event: MessageEvent): void => { + /** Make sure this message is for us */ + if (event.data.sessionKey) { + this.log('Component manager received message', event.data) + this.componentViewerForSessionKey(event.data.sessionKey)?.handleMessage(event.data) + } + } + + configureForDesktop(): void { + this.desktopManager?.registerUpdateObserver((component: SNComponent) => { + /* Reload theme if active */ + if (component.active && component.isTheme()) { + this.postActiveThemesToAllViewers() + } + }) + } + + postActiveThemesToAllViewers(): void { + for (const viewer of this.viewers) { + viewer.postActiveThemes() + } + } + + getActiveThemes(): SNTheme[] { + if (this.environment === Environment.Mobile) { + throw Error('getActiveThemes must be handled separately by mobile') + } + return this.componentsForArea(ComponentArea.Themes).filter((theme) => { + return theme.active + }) as SNTheme[] + } + + urlForComponent(component: SNComponent): string | undefined { + const platformSupportsOfflineOnly = this.isDesktop + if (component.offlineOnly && !platformSupportsOfflineOnly) { + return undefined + } + + const nativeFeature = FindNativeFeature(component.identifier) + + if (this.isDesktop) { + assert(this.desktopManager) + + if (nativeFeature) { + return `${this.desktopManager.getExtServerHost()}/components/${component.identifier}/${ + nativeFeature.index_path + }` + } else if (component.local_url) { + return component.local_url.replace(DESKTOP_URL_PREFIX, this.desktopManager.getExtServerHost() + '/') + } else { + return component.hosted_url || component.legacy_url + } + } + + const isWeb = this.environment === Environment.Web + if (nativeFeature) { + if (!isWeb) { + throw Error('Mobile must override urlForComponent to handle native paths') + } + return `${window.location.origin}/components/assets/${component.identifier}/${nativeFeature.index_path}` + } + + let url = component.hosted_url || component.legacy_url + if (!url) { + return undefined + } + if (this.isMobile) { + const localReplacement = this.platform === Platform.Ios ? LOCAL_HOST : ANDROID_LOCAL_HOST + url = url.replace(LOCAL_HOST, localReplacement).replace(CUSTOM_LOCAL_HOST, localReplacement) + } + return url + } + + urlsForActiveThemes(): string[] { + const themes = this.getActiveThemes() + const urls = [] + for (const theme of themes) { + const url = this.urlForComponent(theme) + if (url) { + urls.push(url) + } + } + return urls + } + + private findComponent(uuid: UuidString): SNComponent | undefined { + return this.itemManager.findItem(uuid) + } + + findComponentViewer(identifier: string): ComponentViewer | undefined { + return this.viewers.find((viewer) => viewer.identifier === identifier) + } + + componentViewerForSessionKey(key: string): ComponentViewer | undefined { + return this.viewers.find((viewer) => viewer.sessionKey === key) + } + + areRequestedPermissionsValid(component: SNComponent, permissions: ComponentPermission[]): boolean { + for (const permission of permissions) { + if (permission.name === ComponentAction.StreamItems) { + if (!AllowedBatchStreaming.includes(component.identifier)) { + return false + } + const hasNonAllowedBatchPermission = permission.content_types?.some( + (type) => !AllowedBatchContentTypes.includes(type), + ) + if (hasNonAllowedBatchPermission) { + return false + } + } + } + + return true + } + + runWithPermissions( + componentUuid: UuidString, + requiredPermissions: ComponentPermission[], + runFunction: () => void, + ): void { + const component = this.findComponent(componentUuid) + + if (!component) { + void this.alertService.alert( + `Unable to find component with ID ${componentUuid}. Please restart the app and try again.`, + 'An unexpected error occurred', + ) + + return + } + + if (!this.areRequestedPermissionsValid(component, requiredPermissions)) { + console.error('Component is requesting invalid permissions', componentUuid, requiredPermissions) + return + } + + const nativeFeature = FindNativeFeature(component.identifier) + const acquiredPermissions = nativeFeature?.component_permissions || component.permissions + + /* Make copy as not to mutate input values */ + requiredPermissions = Copy(requiredPermissions) as ComponentPermission[] + for (const required of requiredPermissions.slice()) { + /* Remove anything we already have */ + const respectiveAcquired = acquiredPermissions.find((candidate) => candidate.name === required.name) + if (!respectiveAcquired) { + continue + } + /* We now match on name, lets substract from required.content_types anything we have in acquired. */ + const requiredContentTypes = required.content_types + if (!requiredContentTypes) { + /* If this permission does not require any content types (i.e stream-context-item) + then we can remove this from required since we match by name (respectiveAcquired.name === required.name) */ + filterFromArray(requiredPermissions, required) + continue + } + for (const acquiredContentType of respectiveAcquired.content_types!) { + removeFromArray(requiredContentTypes, acquiredContentType) + } + if (requiredContentTypes.length === 0) { + /* We've removed all acquired and end up with zero, means we already have all these permissions */ + filterFromArray(requiredPermissions, required) + } + } + if (requiredPermissions.length > 0) { + this.promptForPermissionsWithAngularAsyncRendering( + component, + requiredPermissions, + // eslint-disable-next-line @typescript-eslint/require-await + async (approved) => { + if (approved) { + runFunction() + } + }, + ) + } else { + runFunction() + } + } + + promptForPermissionsWithAngularAsyncRendering( + component: SNComponent, + permissions: ComponentPermission[], + callback: (approved: boolean) => Promise, + ): void { + setTimeout(() => { + this.promptForPermissions(component, permissions, callback) + }) + } + + promptForPermissions( + component: SNComponent, + permissions: ComponentPermission[], + callback: (approved: boolean) => Promise, + ): void { + const params: PermissionDialog = { + component: component, + permissions: permissions, + permissionsString: this.permissionsStringForPermissions(permissions, component), + actionBlock: callback, + callback: async (approved: boolean) => { + const latestComponent = this.findComponent(component.uuid) + + if (!latestComponent) { + return + } + + if (approved) { + this.log('Changing component to expand permissions', component) + const componentPermissions = Copy(latestComponent.permissions) as ComponentPermission[] + for (const permission of permissions) { + const matchingPermission = componentPermissions.find((candidate) => candidate.name === permission.name) + if (!matchingPermission) { + componentPermissions.push(permission) + } else { + /* Permission already exists, but content_types may have been expanded */ + const contentTypes = matchingPermission.content_types || [] + matchingPermission.content_types = uniq(contentTypes.concat(permission.content_types!)) + } + } + + await this.itemManager.changeItem(component, (m) => { + const mutator = m as ComponentMutator + mutator.permissions = componentPermissions + }) + + void this.syncService.sync() + } + + this.permissionDialogs = this.permissionDialogs.filter((pendingDialog) => { + /* Remove self */ + if (pendingDialog === params) { + pendingDialog.actionBlock && pendingDialog.actionBlock(approved) + return false + } + const containsObjectSubset = (source: ComponentPermission[], target: ComponentPermission[]) => { + return !target.some((val) => !source.find((candidate) => JSON.stringify(candidate) === JSON.stringify(val))) + } + if (pendingDialog.component === component) { + /* remove pending dialogs that are encapsulated by already approved permissions, and run its function */ + if ( + pendingDialog.permissions === permissions || + containsObjectSubset(permissions, pendingDialog.permissions) + ) { + /* If approved, run the action block. Otherwise, if canceled, cancel any + pending ones as well, since the user was explicit in their intentions */ + if (approved) { + pendingDialog.actionBlock && pendingDialog.actionBlock(approved) + } + return false + } + } + return true + }) + + if (this.permissionDialogs.length > 0) { + this.presentPermissionsDialog(this.permissionDialogs[0]) + } + }, + } + /** + * Since these calls are asyncronous, multiple dialogs may be requested at the same time. + * We only want to present one and trigger all callbacks based on one modal result + */ + const existingDialog = find(this.permissionDialogs, { + component: component, + }) + this.permissionDialogs.push(params) + if (!existingDialog) { + this.presentPermissionsDialog(params) + } else { + this.log('Existing dialog, not presenting.') + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + presentPermissionsDialog(_dialog: PermissionDialog): void { + throw 'Must override SNComponentManager.presentPermissionsDialog' + } + + async toggleTheme(uuid: UuidString): Promise { + this.log('Toggling theme', uuid) + + const theme = this.findComponent(uuid) as SNTheme + if (theme.active) { + await this.itemManager.changeComponent(theme, (mutator) => { + mutator.active = false + }) + } else { + const activeThemes = this.getActiveThemes() + + /* Activate current before deactivating others, so as not to flicker */ + await this.itemManager.changeComponent(theme, (mutator) => { + mutator.active = true + }) + + /* Deactive currently active theme(s) if new theme is not layerable */ + if (!theme.isLayerable()) { + await sleep(10) + for (const candidate of activeThemes) { + if (candidate && !candidate.isLayerable()) { + await this.itemManager.changeComponent(candidate, (mutator) => { + mutator.active = false + }) + } + } + } + } + } + + async toggleComponent(uuid: UuidString): Promise { + this.log('Toggling component', uuid) + + const component = this.findComponent(uuid) + + if (!component) { + return + } + + await this.itemManager.changeComponent(component, (mutator) => { + mutator.active = !(mutator.getItem() as SNComponent).active + }) + } + + isComponentActive(component: SNComponent): boolean { + return component.active + } + + allComponentIframes(): HTMLIFrameElement[] { + if (this.isMobile) { + /** + * Retrieving all iframes is typically related to lifecycle management of + * non-editor components. So this function is not useful to mobile. + */ + return [] + } + return Array.from(document.getElementsByTagName('iframe')) + } + + iframeForComponentViewer(viewer: ComponentViewer): HTMLIFrameElement | undefined { + return viewer.getIframe() + } + + editorForNote(note: SNNote): SNComponent | undefined { + const editors = this.componentsForArea(ComponentArea.Editor) + for (const editor of editors) { + if (editor.isExplicitlyEnabledForItem(note.uuid)) { + return editor + } + } + let defaultEditor + /* No editor found for note. Use default editor, if note does not prefer system editor */ + if (this.isMobile) { + if (!note.mobilePrefersPlainEditor) { + defaultEditor = this.getDefaultEditor() + } + } else { + if (!note.prefersPlainEditor) { + defaultEditor = this.getDefaultEditor() + } + } + if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) { + return defaultEditor + } else { + return undefined + } + } + + getDefaultEditor(): SNComponent { + const editors = this.componentsForArea(ComponentArea.Editor) + if (this.isMobile) { + return editors.filter((e) => { + return e.isMobileDefault + })[0] + } else { + return editors.filter((e) => e.isDefaultEditor())[0] + } + } + + permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string { + if (permissions.length === 0) { + return '.' + } + + let contentTypeStrings: string[] = [] + let contextAreaStrings: string[] = [] + + permissions.forEach((permission) => { + switch (permission.name) { + case ComponentAction.StreamItems: + if (!permission.content_types) { + return + } + permission.content_types.forEach((contentType) => { + const desc = DisplayStringForContentType(contentType) + if (desc) { + contentTypeStrings.push(`${desc}s`) + } else { + contentTypeStrings.push(`items of type ${contentType}`) + } + }) + break + case ComponentAction.StreamContextItem: + { + const componentAreaMapping = { + [ComponentArea.EditorStack]: 'working note', + [ComponentArea.Editor]: 'working note', + [ComponentArea.Themes]: 'Unknown', + } + contextAreaStrings.push(componentAreaMapping[component.area]) + } + break + } + }) + + contentTypeStrings = uniq(contentTypeStrings) + contextAreaStrings = uniq(contextAreaStrings) + + if (contentTypeStrings.length === 0 && contextAreaStrings.length === 0) { + return '.' + } + return contentTypeStrings.concat(contextAreaStrings).join(', ') + '.' + } + + doesEditorChangeRequireAlert(from: SNComponent | undefined, to: SNComponent | undefined): boolean { + const isEitherPlainEditor = !from || !to + const isEitherMarkdown = from?.package_info.file_type === 'md' || to?.package_info.file_type === 'md' + const areBothHtml = from?.package_info.file_type === 'html' && to?.package_info.file_type === 'html' + + if (isEitherPlainEditor || isEitherMarkdown || areBothHtml) { + return false + } else { + return true + } + } + + async showEditorChangeAlert(): Promise { + const shouldChangeEditor = await this.alertService.confirm( + 'Doing so might result in minor formatting changes.', + "Are you sure you want to change this note's type?", + 'Yes, change it', + ) + + return shouldChangeEditor + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts new file mode 100644 index 000000000..0a7a1ad98 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/ComponentViewer.ts @@ -0,0 +1,913 @@ +import { SNPreferencesService } from '../Preferences/PreferencesService' +import { FeatureStatus, FeaturesEvent } from '@Lib/Services/Features' +import { Environment, Platform, AlertService } from '@standardnotes/services' +import { SNFeaturesService } from '@Lib/Services' +import { + SNComponent, + PrefKey, + NoteContent, + MutationType, + CreateDecryptedItemFromPayload, + DecryptedItemInterface, + DeletedItemInterface, + EncryptedItemInterface, + isDecryptedItem, + isNotEncryptedItem, + isNote, + CreateComponentRetrievedContextPayload, + createComponentCreatedContextPayload, + DecryptedPayload, + ItemContent, + ComponentDataDomain, + PayloadEmitSource, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import find from 'lodash/find' +import uniq from 'lodash/uniq' +import remove from 'lodash/remove' +import { SNSyncService } from '@Lib/Services/Sync/SyncService' +import { environmentToString, platformToString } from '@Lib/Application/Platforms' +import { + ComponentMessage, + OutgoingItemMessagePayload, + MessageReply, + StreamItemsMessageData, + AllowedBatchContentTypes, + IncomingComponentItemPayload, + DeleteItemsMessageData, + MessageReplyData, +} from './Types' +import { ComponentAction, ComponentPermission, ComponentArea, FindNativeFeature } from '@standardnotes/features' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { UuidString } from '@Lib/Types/UuidString' +import { ContentType } from '@standardnotes/common' +import { + isString, + extendArray, + Copy, + removeFromArray, + log, + nonSecureRandomIdentifier, + UuidGenerator, + Uuids, + sureSearchArray, + isNotUndefined, +} from '@standardnotes/utils' +import { MessageData } from '..' + +type RunWithPermissionsCallback = ( + componentUuid: UuidString, + requiredPermissions: ComponentPermission[], + runFunction: () => void, +) => void + +type ComponentManagerFunctions = { + runWithPermissions: RunWithPermissionsCallback + urlsForActiveThemes: () => string[] +} + +const ReadwriteActions = [ + ComponentAction.SaveItems, + ComponentAction.AssociateItem, + ComponentAction.DeassociateItem, + ComponentAction.CreateItem, + ComponentAction.CreateItems, + ComponentAction.DeleteItems, + ComponentAction.SetComponentData, +] + +export type ActionObserver = (action: ComponentAction, messageData: MessageData) => void + +export enum ComponentViewerEvent { + FeatureStatusUpdated = 'FeatureStatusUpdated', +} +type EventObserver = (event: ComponentViewerEvent) => void + +export enum ComponentViewerError { + OfflineRestricted = 'OfflineRestricted', + MissingUrl = 'MissingUrl', +} + +type Writeable = { -readonly [P in keyof T]: T[P] } + +export class ComponentViewer { + private streamItems?: ContentType[] + private streamContextItemOriginalMessage?: ComponentMessage + private streamItemsOriginalMessage?: ComponentMessage + private removeItemObserver: () => void + private loggingEnabled = false + public identifier = nonSecureRandomIdentifier() + private actionObservers: ActionObserver[] = [] + public overrideContextItem?: DecryptedItemInterface + private featureStatus: FeatureStatus + private removeFeaturesObserver: () => void + private eventObservers: EventObserver[] = [] + private dealloced = false + + private window?: Window + private hidden = false + private readonly = false + public lockReadonly = false + public sessionKey?: string + + constructor( + public readonly component: SNComponent, + private itemManager: ItemManager, + private syncService: SNSyncService, + private alertService: AlertService, + private preferencesSerivce: SNPreferencesService, + featuresService: SNFeaturesService, + private environment: Environment, + private platform: Platform, + private componentManagerFunctions: ComponentManagerFunctions, + public readonly url?: string, + private contextItemUuid?: UuidString, + actionObserver?: ActionObserver, + ) { + this.removeItemObserver = this.itemManager.addObserver( + ContentType.Any, + ({ changed, inserted, removed, source, sourceKey }) => { + if (this.dealloced) { + return + } + const items = [...changed, ...inserted, ...removed] + this.handleChangesInItems(items, source, sourceKey) + }, + ) + if (actionObserver) { + this.actionObservers.push(actionObserver) + } + + this.featureStatus = featuresService.getFeatureStatus(component.identifier) + + this.removeFeaturesObserver = featuresService.addEventObserver((event) => { + if (this.dealloced) { + return + } + if (event === FeaturesEvent.FeaturesUpdated) { + const featureStatus = featuresService.getFeatureStatus(component.identifier) + + if (featureStatus !== this.featureStatus) { + this.featureStatus = featureStatus + this.notifyEventObservers(ComponentViewerEvent.FeatureStatusUpdated) + } + } + }) + + this.log('Constructor', this) + } + + get isDesktop(): boolean { + return this.environment === Environment.Desktop + } + + get isMobile(): boolean { + return this.environment === Environment.Mobile + } + + public destroy(): void { + this.log('Destroying', this) + this.deinit() + } + + private deinit(): void { + this.dealloced = true + ;(this.component as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.preferencesSerivce as unknown) = undefined + ;(this.componentManagerFunctions as unknown) = undefined + + this.eventObservers.length = 0 + this.actionObservers.length = 0 + + this.removeFeaturesObserver() + ;(this.removeFeaturesObserver as unknown) = undefined + + this.removeItemObserver() + ;(this.removeItemObserver as unknown) = undefined + } + + public addEventObserver(observer: EventObserver): () => void { + this.eventObservers.push(observer) + + const thislessChangeObservers = this.eventObservers + return () => { + removeFromArray(thislessChangeObservers, observer) + } + } + + private notifyEventObservers(event: ComponentViewerEvent): void { + for (const observer of this.eventObservers) { + observer(event) + } + } + + public addActionObserver(observer: ActionObserver): () => void { + this.actionObservers.push(observer) + + const thislessChangeObservers = this.actionObservers + return () => { + removeFromArray(thislessChangeObservers, observer) + } + } + + public setReadonly(readonly: boolean): void { + if (this.lockReadonly) { + throw Error('Attempting to set readonly on lockedReadonly component viewer') + } + this.readonly = readonly + } + + get componentUuid(): string { + return this.component.uuid + } + + public getFeatureStatus(): FeatureStatus { + return this.featureStatus + } + + private isOfflineRestricted(): boolean { + return this.component.offlineOnly && !this.isDesktop + } + + private isNativeFeature(): boolean { + return !!FindNativeFeature(this.component.identifier) + } + + private hasUrlError(): boolean { + if (this.isNativeFeature()) { + return false + } + return this.isDesktop + ? !this.component.local_url && !this.component.hasValidHostedUrl() + : !this.component.hasValidHostedUrl() + } + + public shouldRender(): boolean { + return this.getError() == undefined + } + + public getError(): ComponentViewerError | undefined { + if (this.isOfflineRestricted()) { + return ComponentViewerError.OfflineRestricted + } + if (this.hasUrlError()) { + return ComponentViewerError.MissingUrl + } + + return undefined + } + + private updateOurComponentRefFromChangedItems(items: DecryptedItemInterface[]): void { + const updatedComponent = items.find((item) => item.uuid === this.component.uuid) + if (updatedComponent && isDecryptedItem(updatedComponent)) { + ;(this.component as Writeable) = updatedComponent as SNComponent + } + } + + handleChangesInItems( + items: (DecryptedItemInterface | DeletedItemInterface | EncryptedItemInterface)[], + source: PayloadEmitSource, + sourceKey?: string, + ): void { + const nonencryptedItems = items.filter(isNotEncryptedItem) + const nondeletedItems = nonencryptedItems.filter(isDecryptedItem) + + this.updateOurComponentRefFromChangedItems(nondeletedItems) + + const areWeOriginator = sourceKey && sourceKey === this.component.uuid + if (areWeOriginator) { + return + } + + if (this.streamItems) { + const relevantItems = nonencryptedItems.filter((item) => { + return this.streamItems?.includes(item.content_type) + }) + + if (relevantItems.length > 0) { + this.sendManyItemsThroughBridge(relevantItems) + } + } + + if (this.streamContextItemOriginalMessage) { + const matchingItem = find(nondeletedItems, { uuid: this.contextItemUuid }) + if (matchingItem) { + this.sendContextItemThroughBridge(matchingItem, source) + } + } + } + + sendManyItemsThroughBridge(items: (DecryptedItemInterface | DeletedItemInterface)[]): void { + const requiredPermissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: this.streamItems!.sort(), + }, + ] + + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { + this.sendItemsInReply(items, this.streamItemsOriginalMessage!) + }) + } + + sendContextItemThroughBridge(item: DecryptedItemInterface, source?: PayloadEmitSource): void { + const requiredContextPermissions = [ + { + name: ComponentAction.StreamContextItem, + }, + ] as ComponentPermission[] + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredContextPermissions, () => { + this.log( + 'Send context item in reply', + 'component:', + this.component, + 'item: ', + item, + 'originalMessage: ', + this.streamContextItemOriginalMessage, + ) + const response: MessageReplyData = { + item: this.jsonForItem(item, source), + } + this.replyToMessage(this.streamContextItemOriginalMessage!, response) + }) + } + + private log(message: string, ...args: unknown[]): void { + if (this.loggingEnabled) { + log('ComponentViewer', message, args) + } + } + + private sendItemsInReply( + items: (DecryptedItemInterface | DeletedItemInterface)[], + message: ComponentMessage, + source?: PayloadEmitSource, + ): void { + this.log('Send items in reply', this.component, items, message) + + const responseData: MessageReplyData = {} + + const mapped = items.map((item) => { + return this.jsonForItem(item, source) + }) + + responseData.items = mapped + + this.replyToMessage(message, responseData) + } + + private jsonForItem( + item: DecryptedItemInterface | DeletedItemInterface, + source?: PayloadEmitSource, + ): OutgoingItemMessagePayload { + const isMetadatUpdate = + source === PayloadEmitSource.RemoteSaved || + source === PayloadEmitSource.OfflineSyncSaved || + source === PayloadEmitSource.PreSyncSave + + const params: OutgoingItemMessagePayload = { + uuid: item.uuid, + content_type: item.content_type, + created_at: item.created_at, + updated_at: item.serverUpdatedAt, + isMetadataUpdate: isMetadatUpdate, + } + + if (isDecryptedItem(item)) { + params.content = this.contentForItem(item) + const globalComponentData = item.getDomainData(ComponentDataDomain) || {} + const thisComponentData = globalComponentData[this.component.getClientDataKey()] || {} + params.clientData = thisComponentData as Record + } else { + params.deleted = true + } + + return this.responseItemsByRemovingPrivateProperties([params])[0] + } + + contentForItem(item: DecryptedItemInterface): ItemContent | undefined { + if (isNote(item)) { + const content = item.content + const spellcheck = + item.spellcheck != undefined + ? item.spellcheck + : this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true) + + return { + ...content, + spellcheck, + } as NoteContent + } + + return item.content + } + + private replyToMessage(originalMessage: ComponentMessage, replyData: MessageReplyData): void { + const reply: MessageReply = { + action: ComponentAction.Reply, + original: originalMessage, + data: replyData, + } + this.sendMessage(reply) + } + + /** + * @param essential If the message is non-essential, no alert will be shown + * if we can no longer find the window. + */ + sendMessage(message: ComponentMessage | MessageReply, essential = true): void { + const permissibleActionsWhileHidden = [ComponentAction.ComponentRegistered, ComponentAction.ActivateThemes] + + if (this.hidden && !permissibleActionsWhileHidden.includes(message.action)) { + this.log('Component disabled for current item, ignoring messages.', this.component.name) + return + } + + if (!this.window && message.action === ComponentAction.Reply) { + this.log('Component has been deallocated in between message send and reply', this.component, message) + return + } + this.log('Send message to component', this.component, 'message: ', message) + + let origin = this.url + if (!origin || !this.window) { + if (essential) { + void this.alertService.alert( + `Standard Notes is trying to communicate with ${this.component.name}, ` + + 'but an error is occurring. Please restart this extension and try again.', + ) + } + return + } + + if (!origin.startsWith('http') && !origin.startsWith('file')) { + /* Native extension running in web, prefix current host */ + origin = window.location.href + origin + } + + /* Mobile messaging requires json */ + this.window.postMessage(this.isMobile ? JSON.stringify(message) : message, origin) + } + + private responseItemsByRemovingPrivateProperties( + responseItems: T[], + removeUrls = false, + ): T[] { + /* Don't allow component to overwrite these properties. */ + let privateContentProperties = ['autoupdateDisabled', 'permissions', 'active'] + if (removeUrls) { + privateContentProperties = privateContentProperties.concat(['hosted_url', 'local_url']) + } + + return responseItems.map((responseItem) => { + const privateProperties = privateContentProperties.slice() + /** Server extensions are allowed to modify url property */ + if (removeUrls) { + privateProperties.push('url') + } + if (!responseItem.content || isString(responseItem.content)) { + return responseItem + } + + let content: Partial = {} + for (const [key, value] of Object.entries(responseItem.content)) { + if (!privateProperties.includes(key)) { + content = { + ...content, + [key]: value, + } + } + } + + return { + ...responseItem, + content: content, + } + }) + } + + public getWindow(): Window | undefined { + return this.window + } + + /** Called by client when the iframe is ready */ + public setWindow(window: Window): void { + if (this.window) { + throw Error('Attempting to override component viewer window. Create a new component viewer instead.') + } + + this.log('setWindow', 'component: ', this.component, 'window: ', window) + + this.window = window + this.sessionKey = UuidGenerator.GenerateUuid() + + this.sendMessage({ + action: ComponentAction.ComponentRegistered, + sessionKey: this.sessionKey, + componentData: this.component.componentData, + data: { + uuid: this.component.uuid, + environment: environmentToString(this.environment), + platform: platformToString(this.platform), + activeThemeUrls: this.componentManagerFunctions.urlsForActiveThemes(), + }, + }) + + this.log('setWindow got new sessionKey', this.sessionKey) + + this.postActiveThemes() + } + + postActiveThemes(): void { + const urls = this.componentManagerFunctions.urlsForActiveThemes() + const data: MessageData = { + themes: urls, + } + + const message: ComponentMessage = { + action: ComponentAction.ActivateThemes, + data: data, + } + + this.sendMessage(message, false) + } + + /* A hidden component will not receive messages. However, when a component is unhidden, + * we need to send it any items it may have registered streaming for. */ + public setHidden(hidden: boolean): void { + if (hidden) { + this.hidden = true + } else if (this.hidden) { + this.hidden = false + + if (this.streamContextItemOriginalMessage) { + this.handleStreamContextItemMessage(this.streamContextItemOriginalMessage) + } + + if (this.streamItems) { + this.handleStreamItemsMessage(this.streamItemsOriginalMessage!) + } + } + } + + handleMessage(message: ComponentMessage): void { + this.log('Handle message', message, this) + if (!this.component) { + this.log('Component not defined for message, returning', message) + void this.alertService.alert( + 'A component is trying to communicate with Standard Notes, ' + + 'but there is an error establishing a bridge. Please restart the app and try again.', + ) + return + } + if (this.readonly && ReadwriteActions.includes(message.action)) { + void this.alertService.alert( + `${this.component.name} is trying to save, but it is in a locked state and cannot accept changes.`, + ) + return + } + + const messageHandlers: Partial void>> = { + [ComponentAction.StreamItems]: this.handleStreamItemsMessage.bind(this), + [ComponentAction.StreamContextItem]: this.handleStreamContextItemMessage.bind(this), + [ComponentAction.SetComponentData]: this.handleSetComponentDataMessage.bind(this), + [ComponentAction.DeleteItems]: this.handleDeleteItemsMessage.bind(this), + [ComponentAction.CreateItems]: this.handleCreateItemsMessage.bind(this), + [ComponentAction.CreateItem]: this.handleCreateItemsMessage.bind(this), + [ComponentAction.SaveItems]: this.handleSaveItemsMessage.bind(this), + [ComponentAction.SetSize]: this.handleSetSizeEvent.bind(this), + } + + const handler = messageHandlers[message.action] + handler?.(message) + + for (const observer of this.actionObservers) { + observer(message.action, message.data) + } + } + + handleStreamItemsMessage(message: ComponentMessage): void { + const data = message.data as StreamItemsMessageData + const types = data.content_types.filter((type) => AllowedBatchContentTypes.includes(type)).sort() + const requiredPermissions = [ + { + name: ComponentAction.StreamItems, + content_types: types, + }, + ] + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { + if (!this.streamItems) { + this.streamItems = types + this.streamItemsOriginalMessage = message + } + /* Push immediately now */ + const items: DecryptedItemInterface[] = [] + for (const contentType of types) { + extendArray(items, this.itemManager.getItems(contentType)) + } + this.sendItemsInReply(items, message) + }) + } + + handleStreamContextItemMessage(message: ComponentMessage): void { + const requiredPermissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamContextItem, + }, + ] + + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, () => { + if (!this.streamContextItemOriginalMessage) { + this.streamContextItemOriginalMessage = message + } + const matchingItem = this.overrideContextItem || this.itemManager.findItem(this.contextItemUuid!) + if (matchingItem) { + this.sendContextItemThroughBridge(matchingItem) + } + }) + } + + /** + * Save items is capable of saving existing items, and also creating new ones + * if they don't exist. + */ + handleSaveItemsMessage(message: ComponentMessage): void { + let responsePayloads = message.data.items as IncomingComponentItemPayload[] + const requiredPermissions = [] + + /* Pending as in needed to be accounted for in permissions. */ + const pendingResponseItems = responsePayloads.slice() + + for (const responseItem of responsePayloads.slice()) { + if (responseItem.uuid === this.contextItemUuid) { + requiredPermissions.push({ + name: ComponentAction.StreamContextItem, + }) + removeFromArray(pendingResponseItems, responseItem) + /* We break because there can only be one context item */ + break + } + } + + /* Check to see if additional privileges are required */ + if (pendingResponseItems.length > 0) { + const requiredContentTypes = uniq( + pendingResponseItems.map((item) => { + return item.content_type + }), + ).sort() + + requiredPermissions.push({ + name: ComponentAction.StreamItems, + content_types: requiredContentTypes, + } as ComponentPermission) + } + + this.componentManagerFunctions.runWithPermissions( + this.component.uuid, + requiredPermissions, + + async () => { + responsePayloads = this.responseItemsByRemovingPrivateProperties(responsePayloads, true) + + /* Filter locked items */ + const uuids = Uuids(responsePayloads) + const items = this.itemManager.findItemsIncludingBlanks(uuids) + let lockedCount = 0 + let lockedNoteCount = 0 + + for (const item of items) { + if (!item) { + continue + } + + if (item.locked) { + remove(responsePayloads, { uuid: item.uuid }) + lockedCount++ + if (item.content_type === ContentType.Note) { + lockedNoteCount++ + } + } + } + + if (lockedNoteCount === 1) { + void this.alertService.alert( + 'The note you are attempting to save has editing disabled', + 'Note has Editing Disabled', + ) + return + } else if (lockedCount > 0) { + const itemNoun = lockedCount === 1 ? 'item' : lockedNoteCount === lockedCount ? 'notes' : 'items' + const auxVerb = lockedCount === 1 ? 'has' : 'have' + void this.alertService.alert( + `${lockedCount} ${itemNoun} you are attempting to save ${auxVerb} editing disabled.`, + 'Items have Editing Disabled', + ) + + return + } + + const contextualPayloads = responsePayloads.map((responseItem) => { + return CreateComponentRetrievedContextPayload(responseItem) + }) + + for (const contextualPayload of contextualPayloads) { + const item = this.itemManager.findItem(contextualPayload.uuid) + if (!item) { + const payload = new DecryptedPayload({ + ...PayloadTimestampDefaults(), + ...contextualPayload, + }) + const template = CreateDecryptedItemFromPayload(payload) + await this.itemManager.insertItem(template) + } else { + if (contextualPayload.content_type !== item.content_type) { + throw Error('Extension is trying to modify content type of item.') + } + } + } + + await this.itemManager.changeItems( + items.filter(isNotUndefined), + (mutator) => { + const contextualPayload = sureSearchArray(contextualPayloads, { + uuid: mutator.getUuid(), + }) + + mutator.setCustomContent(contextualPayload.content) + + const responseItem = sureSearchArray(responsePayloads, { + uuid: mutator.getUuid(), + }) + + if (responseItem.clientData) { + const allComponentData = Copy(mutator.getItem().getDomainData(ComponentDataDomain) || {}) + allComponentData[this.component.getClientDataKey()] = responseItem.clientData + mutator.setDomainData(allComponentData, ComponentDataDomain) + } + }, + MutationType.UpdateUserTimestamps, + PayloadEmitSource.ComponentRetrieved, + this.component.uuid, + ) + + this.syncService + .sync({ + onPresyncSave: () => { + this.replyToMessage(message, {}) + }, + }) + .catch(() => { + this.replyToMessage(message, { + error: 'save-error', + }) + }) + }, + ) + } + + handleCreateItemsMessage(message: ComponentMessage): void { + let responseItems = (message.data.item ? [message.data.item] : message.data.items) as IncomingComponentItemPayload[] + + const uniqueContentTypes = uniq( + responseItems.map((item) => { + return item.content_type + }), + ) + + const requiredPermissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: uniqueContentTypes, + }, + ] + + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => { + responseItems = this.responseItemsByRemovingPrivateProperties(responseItems) + const processedItems = [] + + for (const responseItem of responseItems) { + if (!responseItem.uuid) { + responseItem.uuid = UuidGenerator.GenerateUuid() + } + + const contextualPayload = createComponentCreatedContextPayload(responseItem) + const payload = new DecryptedPayload({ + ...PayloadTimestampDefaults(), + ...contextualPayload, + }) + + const template = CreateDecryptedItemFromPayload(payload) + const item = await this.itemManager.insertItem(template) + + await this.itemManager.changeItem( + item, + (mutator) => { + if (responseItem.clientData) { + const allComponentData = Copy(item.getDomainData(ComponentDataDomain) || {}) + allComponentData[this.component.getClientDataKey()] = responseItem.clientData + mutator.setDomainData(allComponentData, ComponentDataDomain) + } + }, + MutationType.UpdateUserTimestamps, + PayloadEmitSource.ComponentCreated, + this.component.uuid, + ) + processedItems.push(item) + } + + void this.syncService.sync() + + const reply = + message.action === ComponentAction.CreateItem + ? { item: this.jsonForItem(processedItems[0]) } + : { + items: processedItems.map((item) => { + return this.jsonForItem(item) + }), + } + this.replyToMessage(message, reply) + }) + } + + handleDeleteItemsMessage(message: ComponentMessage): void { + const data = message.data as DeleteItemsMessageData + const items = data.items.filter((item) => AllowedBatchContentTypes.includes(item.content_type)) + + const requiredContentTypes = uniq(items.map((item) => item.content_type)).sort() as ContentType[] + + const requiredPermissions: ComponentPermission[] = [ + { + name: ComponentAction.StreamItems, + content_types: requiredContentTypes, + }, + ] + + this.componentManagerFunctions.runWithPermissions(this.component.uuid, requiredPermissions, async () => { + const itemsData = items + const noun = itemsData.length === 1 ? 'item' : 'items' + let reply = null + const didConfirm = await this.alertService.confirm(`Are you sure you want to delete ${itemsData.length} ${noun}?`) + + if (didConfirm) { + /* Filter for any components and deactivate before deleting */ + for (const itemData of itemsData) { + const item = this.itemManager.findItem(itemData.uuid) + if (!item) { + void this.alertService.alert('The item you are trying to delete cannot be found.') + continue + } + await this.itemManager.setItemToBeDeleted(item, PayloadEmitSource.ComponentRetrieved) + } + + void this.syncService.sync() + + reply = { deleted: true } + } else { + /* Rejected by user */ + reply = { deleted: false } + } + + this.replyToMessage(message, reply) + }) + } + + handleSetComponentDataMessage(message: ComponentMessage): void { + const noPermissionsRequired: ComponentPermission[] = [] + this.componentManagerFunctions.runWithPermissions(this.component.uuid, noPermissionsRequired, async () => { + await this.itemManager.changeComponent(this.component, (mutator) => { + mutator.componentData = message.data.componentData || {} + }) + + void this.syncService.sync() + }) + } + + handleSetSizeEvent(message: ComponentMessage): void { + if (this.component.area !== ComponentArea.EditorStack) { + return + } + + const parent = this.getIframe()?.parentElement + if (!parent) { + return + } + + const data = message.data + const widthString = isString(data.width) ? data.width : `${data.width}px` + const heightString = isString(data.height) ? data.height : `${data.height}px` + if (parent) { + parent.setAttribute('style', `width:${widthString}; height:${heightString};`) + } + } + + getIframe(): HTMLIFrameElement | undefined { + return Array.from(document.getElementsByTagName('iframe')).find( + (iframe) => iframe.dataset.componentViewerId === this.identifier, + ) + } +} diff --git a/packages/snjs/lib/Services/ComponentManager/Types.ts b/packages/snjs/lib/Services/ComponentManager/Types.ts new file mode 100644 index 000000000..5a4858310 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/Types.ts @@ -0,0 +1,134 @@ +import { + ComponentArea, + ComponentAction, + ComponentPermission, + FeatureIdentifier, + LegacyFileSafeIdentifier, +} from '@standardnotes/features' +import { ItemContent, SNComponent, DecryptedTransferPayload } from '@standardnotes/models' +import { UuidString } from '@Lib/Types/UuidString' +import { ContentType } from '@standardnotes/common' + +export interface DesktopManagerInterface { + syncComponentsInstallation(components: SNComponent[]): void + registerUpdateObserver(callback: (component: SNComponent) => void): void + getExtServerHost(): string +} + +export type IncomingComponentItemPayload = DecryptedTransferPayload & { + clientData: Record +} + +export type OutgoingItemMessagePayload = { + uuid: string + content_type: ContentType + created_at: Date + updated_at: Date + deleted?: boolean + content?: ItemContent + clientData?: Record + + /** + * isMetadataUpdate implies that the extension should make reference of updated + * metadata, but not update content values as they may be stale relative to what the + * extension currently has. + */ + isMetadataUpdate: boolean +} + +/** + * Extensions allowed to batch stream AllowedBatchContentTypes + */ +export const AllowedBatchStreaming = Object.freeze([ + LegacyFileSafeIdentifier, + FeatureIdentifier.DeprecatedFileSafe, + FeatureIdentifier.DeprecatedBoldEditor, +]) + +/** + * Content types which are allowed to be managed/streamed in bulk by a component. + */ +export const AllowedBatchContentTypes = Object.freeze([ + ContentType.FilesafeCredentials, + ContentType.FilesafeFileMetadata, + ContentType.FilesafeIntegration, +]) + +export type StreamObserver = { + identifier: string + componentUuid: UuidString + area: ComponentArea + originalMessage: ComponentMessage + /** contentTypes is optional in the case of a context stream observer */ + contentTypes?: ContentType[] +} + +export type PermissionDialog = { + component: SNComponent + permissions: ComponentPermission[] + permissionsString: string + actionBlock: (approved: boolean) => void + callback: (approved: boolean) => void +} + +export enum KeyboardModifier { + Shift = 'Shift', + Ctrl = 'Control', + Meta = 'Meta', +} + +export type MessageData = Partial<{ + /** Related to the stream-item-context action */ + item?: IncomingComponentItemPayload + /** Related to the stream-items action */ + content_types?: ContentType[] + items?: IncomingComponentItemPayload[] + /** Related to the request-permission action */ + permissions?: ComponentPermission[] + /** Related to the component-registered action */ + componentData?: Record + uuid?: UuidString + environment?: string + platform?: string + activeThemeUrls?: string[] + /** Related to set-size action */ + width?: string | number + height?: string | number + type?: string + /** Related to themes action */ + themes?: string[] + /** Related to clear-selection action */ + content_type?: ContentType + /** Related to key-pressed action */ + keyboardModifier?: KeyboardModifier +}> + +export type MessageReplyData = { + approved?: boolean + deleted?: boolean + error?: string + item?: OutgoingItemMessagePayload + items?: OutgoingItemMessagePayload[] + themes?: string[] +} + +export type StreamItemsMessageData = MessageData & { + content_types: ContentType[] +} + +export type DeleteItemsMessageData = MessageData & { + items: OutgoingItemMessagePayload[] +} + +export type ComponentMessage = { + action: ComponentAction + sessionKey?: string + componentData?: Record + data: MessageData +} + +export type MessageReply = { + action: ComponentAction + original: ComponentMessage + data: MessageReplyData +} diff --git a/packages/snjs/lib/Services/ComponentManager/index.ts b/packages/snjs/lib/Services/ComponentManager/index.ts new file mode 100644 index 000000000..239593d70 --- /dev/null +++ b/packages/snjs/lib/Services/ComponentManager/index.ts @@ -0,0 +1,3 @@ +export * from './ComponentManager' +export * from './ComponentViewer' +export * from './Types' diff --git a/packages/snjs/lib/Services/Features/ClientInterface.ts b/packages/snjs/lib/Services/Features/ClientInterface.ts new file mode 100644 index 000000000..82f223da0 --- /dev/null +++ b/packages/snjs/lib/Services/Features/ClientInterface.ts @@ -0,0 +1,36 @@ +import { FeatureStatus, SetOfflineFeaturesFunctionResponse } from './Types' +import { FeatureDescription, FeatureIdentifier } from '@standardnotes/features' +import { SNComponent } from '@standardnotes/models' +import { RoleName } from '@standardnotes/common' + +export interface FeaturesClientInterface { + downloadExternalFeature(urlOrCode: string): Promise + + getUserFeature(featureId: FeatureIdentifier): FeatureDescription | undefined + + getFeatureStatus(featureId: FeatureIdentifier): FeatureStatus + + hasMinimumRole(role: RoleName): boolean + + setOfflineFeaturesCode(code: string): Promise + + hasOfflineRepo(): boolean + + deleteOfflineFeatureRepo(): Promise + + isThirdPartyFeature(identifier: string): boolean + + toggleExperimentalFeature(identifier: FeatureIdentifier): void + + getExperimentalFeatures(): FeatureIdentifier[] + + getEnabledExperimentalFeatures(): FeatureIdentifier[] + + enableExperimentalFeature(identifier: FeatureIdentifier): void + + disableExperimentalFeature(identifier: FeatureIdentifier): void + + isExperimentalFeatureEnabled(identifier: FeatureIdentifier): boolean + + isExperimentalFeature(identifier: FeatureIdentifier): boolean +} diff --git a/packages/snjs/lib/Services/Features/FeaturesService.spec.ts b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts new file mode 100644 index 000000000..0d6487b68 --- /dev/null +++ b/packages/snjs/lib/Services/Features/FeaturesService.spec.ts @@ -0,0 +1,799 @@ +import { ItemInterface, SNComponent, SNFeatureRepo } from '@standardnotes/models' +import { SNSyncService } from '../Sync/SyncService' +import { SettingName } from '@standardnotes/settings' +import { + ItemManager, + AlertService, + SNApiService, + UserService, + SNSessionManager, + DiskStorageService, + StorageKey, +} from '@Lib/index' +import { FeatureStatus, SNFeaturesService } from '@Lib/Services/Features' +import { ContentType, RoleName } from '@standardnotes/common' +import { FeatureDescription, FeatureIdentifier, GetFeatures } from '@standardnotes/features' +import { SNWebSocketsService } from '../Api/WebsocketsService' +import { SNSettingsService } from '../Settings' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { convertTimestampToMilliseconds } from '@standardnotes/utils' +import { InternalEventBusInterface } from '@standardnotes/services' + +describe('featuresService', () => { + let storageService: DiskStorageService + let apiService: SNApiService + let itemManager: ItemManager + let webSocketsService: SNWebSocketsService + let settingsService: SNSettingsService + let userService: UserService + let syncService: SNSyncService + let alertService: AlertService + let sessionManager: SNSessionManager + let crypto: PureCryptoInterface + let roles: RoleName[] + let features: FeatureDescription[] + let items: ItemInterface[] + let now: Date + let tomorrow_server: number + let tomorrow_client: number + let internalEventBus: InternalEventBusInterface + const expiredDate = new Date(new Date().getTime() - 1000).getTime() + + const createService = () => { + return new SNFeaturesService( + storageService, + apiService, + itemManager, + webSocketsService, + settingsService, + userService, + syncService, + alertService, + sessionManager, + crypto, + internalEventBus, + ) + } + + beforeEach(() => { + roles = [RoleName.CoreUser, RoleName.PlusUser] + + now = new Date() + tomorrow_client = now.setDate(now.getDate() + 1) + tomorrow_server = convertTimestampToMilliseconds(tomorrow_client * 1_000) + + features = [ + { + ...GetFeatures().find((f) => f.identifier === FeatureIdentifier.MidnightTheme), + expires_at: tomorrow_server, + }, + { + ...GetFeatures().find((f) => f.identifier === FeatureIdentifier.PlusEditor), + expires_at: tomorrow_server, + }, + ] as jest.Mocked + + items = [] as jest.Mocked + + storageService = {} as jest.Mocked + storageService.setValue = jest.fn() + storageService.getValue = jest.fn() + + apiService = {} as jest.Mocked + apiService.addEventObserver = jest.fn() + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + apiService.downloadOfflineFeaturesFromRepo = jest.fn().mockReturnValue({ + features, + }) + apiService.isThirdPartyHostUsed = jest.fn().mockReturnValue(false) + + itemManager = {} as jest.Mocked + itemManager.getItems = jest.fn().mockReturnValue(items) + itemManager.createItem = jest.fn() + itemManager.createTemplateItem = jest.fn().mockReturnValue({}) + itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + itemManager.setItemsToBeDeleted = jest.fn() + itemManager.addObserver = jest.fn() + itemManager.changeItem = jest.fn() + itemManager.changeFeatureRepo = jest.fn() + + webSocketsService = {} as jest.Mocked + webSocketsService.addEventObserver = jest.fn() + + settingsService = {} as jest.Mocked + settingsService.updateSetting = jest.fn() + + userService = {} as jest.Mocked + userService.addEventObserver = jest.fn() + + syncService = {} as jest.Mocked + syncService.sync = jest.fn() + + alertService = {} as jest.Mocked + alertService.confirm = jest.fn().mockReturnValue(true) + alertService.alert = jest.fn() + + sessionManager = {} as jest.Mocked + sessionManager.isSignedIntoFirstPartyServer = jest.fn() + sessionManager.getUser = jest.fn() + + crypto = {} as jest.Mocked + crypto.base64Decode = jest.fn() + + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + }) + + describe('experimental features', () => { + it('enables/disables an experimental feature', async () => { + storageService.getValue = jest.fn().mockReturnValue(GetFeatures()) + + const featuresService = createService() + featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor]) + featuresService.initializeFromDisk() + + featuresService.enableExperimentalFeature(FeatureIdentifier.PlusEditor) + + expect(featuresService.isExperimentalFeatureEnabled(FeatureIdentifier.PlusEditor)).toEqual(true) + + featuresService.disableExperimentalFeature(FeatureIdentifier.PlusEditor) + + expect(featuresService.isExperimentalFeatureEnabled(FeatureIdentifier.PlusEditor)).toEqual(false) + }) + + it('does not create a component for not enabled experimental feature', async () => { + const features = [ + { + identifier: FeatureIdentifier.PlusEditor, + expires_at: tomorrow_server, + content_type: ContentType.Component, + }, + ] + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + + const featuresService = createService() + featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor]) + + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).not.toHaveBeenCalled() + }) + + it('does create a component for enabled experimental feature', async () => { + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features: GetFeatures(), + }, + }) + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + + const featuresService = createService() + featuresService.getExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor]) + + featuresService.getEnabledExperimentalFeatures = jest.fn().mockReturnValue([FeatureIdentifier.PlusEditor]) + + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).toHaveBeenCalled() + }) + }) + + describe('loadUserRoles()', () => { + it('retrieves user roles and features from storage', async () => { + await createService().initializeFromDisk() + expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserRoles, undefined, []) + expect(storageService.getValue).toHaveBeenCalledWith(StorageKey.UserFeatures, undefined, []) + }) + }) + + describe('updateRoles()', () => { + it('saves new roles to storage and fetches features if a role has been added', async () => { + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles) + expect(apiService.getUserFeatures).toHaveBeenCalledWith('123') + }) + + it('saves new roles to storage and fetches features if a role has been removed', async () => { + const newRoles = [RoleName.CoreUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserRoles, newRoles) + expect(apiService.getUserFeatures).toHaveBeenCalledWith('123') + }) + + it('saves features to storage when roles change', async () => { + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(storageService.setValue).toHaveBeenCalledWith(StorageKey.UserFeatures, features) + }) + + it('creates items for non-expired features with content type if they do not exist', async () => { + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).toHaveBeenCalledTimes(2) + expect(itemManager.createItem).toHaveBeenCalledWith( + ContentType.Theme, + expect.objectContaining({ + package_info: expect.objectContaining({ + content_type: ContentType.Theme, + expires_at: tomorrow_client, + identifier: FeatureIdentifier.MidnightTheme, + }), + }), + true, + ) + expect(itemManager.createItem).toHaveBeenCalledWith( + ContentType.Component, + expect.objectContaining({ + package_info: expect.objectContaining({ + content_type: ContentType.Component, + expires_at: tomorrow_client, + identifier: FeatureIdentifier.PlusEditor, + }), + }), + true, + ) + }) + + it('if item for a feature exists updates its content', async () => { + const existingItem = new SNComponent({ + uuid: '789', + content_type: ContentType.Component, + content: { + package_info: { + identifier: FeatureIdentifier.PlusEditor, + valid_until: new Date(), + }, + }, + } as never) + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + itemManager.getItems = jest.fn().mockReturnValue([existingItem]) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + + expect(itemManager.changeComponent).toHaveBeenCalledWith(existingItem, expect.any(Function)) + }) + + it('creates items for expired components if they do not exist', async () => { + const newRoles = [...roles, RoleName.PlusUser] + + const now = new Date() + const yesterday_client = now.setDate(now.getDate() - 1) + const yesterday_server = yesterday_client * 1_000 + + storageService.getValue = jest.fn().mockReturnValue(roles) + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features: [ + { + ...features[1], + expires_at: yesterday_server, + }, + ], + }, + }) + + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).toHaveBeenCalledWith( + ContentType.Component, + expect.objectContaining({ + package_info: expect.objectContaining({ + content_type: ContentType.Component, + expires_at: yesterday_client, + identifier: FeatureIdentifier.PlusEditor, + }), + }), + true, + ) + }) + + it('deletes items for expired themes', async () => { + const existingItem = new SNComponent({ + uuid: '456', + content_type: ContentType.Theme, + content: { + package_info: { + identifier: FeatureIdentifier.MidnightTheme, + valid_until: new Date(), + }, + }, + } as never) + + const newRoles = [...roles, RoleName.PlusUser] + + const now = new Date() + const yesterday = now.setDate(now.getDate() - 1) + + itemManager.changeComponent = jest.fn().mockReturnValue(existingItem) + storageService.getValue = jest.fn().mockReturnValue(roles) + itemManager.getItems = jest.fn().mockReturnValue([existingItem]) + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features: [ + { + ...features[0], + expires_at: yesterday, + }, + ], + }, + }) + + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.setItemsToBeDeleted).toHaveBeenCalledWith([existingItem]) + }) + + it('does not create an item for a feature without content type', async () => { + const features = [ + { + identifier: FeatureIdentifier.TagNesting, + expires_at: tomorrow_server, + }, + ] + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).not.toHaveBeenCalled() + }) + + it('does not create an item for deprecated features', async () => { + const features = [ + { + identifier: FeatureIdentifier.DeprecatedBoldEditor, + expires_at: tomorrow_server, + }, + ] + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(itemManager.createItem).not.toHaveBeenCalled() + }) + + it('does nothing after initial update if roles have not changed', async () => { + storageService.getValue = jest.fn().mockReturnValue(roles) + const featuresService = createService() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', roles) + await featuresService.updateRolesAndFetchFeatures('123', roles) + await featuresService.updateRolesAndFetchFeatures('123', roles) + await featuresService.updateRolesAndFetchFeatures('123', roles) + expect(storageService.setValue).toHaveBeenCalledTimes(2) + }) + + it('remote native features should be swapped with compiled version', async () => { + const remoteFeature = { + identifier: FeatureIdentifier.PlusEditor, + content_type: ContentType.Component, + expires_at: tomorrow_server, + } as FeatureDescription + + const newRoles = [...roles, RoleName.PlusUser] + + storageService.getValue = jest.fn().mockReturnValue(roles) + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features: [remoteFeature], + }, + }) + + const featuresService = createService() + const nativeFeature = featuresService['mapRemoteNativeFeatureToStaticFeature'](remoteFeature) + featuresService['mapNativeFeatureToItem'] = jest.fn() + featuresService.initializeFromDisk() + await featuresService.updateRolesAndFetchFeatures('123', newRoles) + expect(featuresService['mapNativeFeatureToItem']).toHaveBeenCalledWith( + nativeFeature, + expect.anything(), + expect.anything(), + ) + }) + + it('feature status', async () => { + const featuresService = createService() + + features = [ + { + identifier: FeatureIdentifier.MidnightTheme, + content_type: ContentType.Theme, + expires_at: tomorrow_server, + role_name: RoleName.PlusUser, + }, + { + identifier: FeatureIdentifier.PlusEditor, + content_type: ContentType.Component, + expires_at: expiredDate, + role_name: RoleName.ProUser, + }, + ] as jest.Mocked + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NotInCurrentPlan) + expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NotInCurrentPlan) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription) + expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NoUserSubscription) + expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NoUserSubscription) + + features = [ + { + identifier: FeatureIdentifier.MidnightTheme, + content_type: ContentType.Theme, + expires_at: expiredDate, + role_name: RoleName.PlusUser, + }, + { + identifier: FeatureIdentifier.PlusEditor, + content_type: ContentType.Component, + expires_at: expiredDate, + role_name: RoleName.ProUser, + }, + ] as jest.Mocked + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features, + }, + }) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.PlusUser]) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe( + FeatureStatus.InCurrentPlanButExpired, + ) + expect(featuresService.getFeatureStatus(FeatureIdentifier.PlusEditor)).toBe(FeatureStatus.NotInCurrentPlan) + expect(featuresService.getFeatureStatus(FeatureIdentifier.SheetsEditor)).toBe(FeatureStatus.NotInCurrentPlan) + }) + + it('third party feature status', async () => { + const featuresService = createService() + + const themeFeature = { + identifier: 'third-party-theme' as FeatureIdentifier, + content_type: ContentType.Theme, + expires_at: tomorrow_server, + role_name: RoleName.CoreUser, + } + + const editorFeature = { + identifier: 'third-party-editor' as FeatureIdentifier, + content_type: ContentType.Component, + expires_at: expiredDate, + role_name: RoleName.PlusUser, + } + + features = [themeFeature, editorFeature] as jest.Mocked + + featuresService['features'] = features + + itemManager.getDisplayableComponents = jest.fn().mockReturnValue([ + new SNComponent({ + uuid: '123', + content_type: ContentType.Theme, + content: { + valid_until: themeFeature.expires_at, + package_info: { + ...themeFeature, + }, + }, + } as never), + new SNComponent({ + uuid: '456', + content_type: ContentType.Component, + content: { + valid_until: new Date(editorFeature.expires_at), + package_info: { + ...editorFeature, + }, + }, + } as never), + ]) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + + expect(featuresService.getFeatureStatus(themeFeature.identifier)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(editorFeature.identifier)).toBe(FeatureStatus.InCurrentPlanButExpired) + expect(featuresService.getFeatureStatus('missing-feature-identifier' as FeatureIdentifier)).toBe( + FeatureStatus.NoUserSubscription, + ) + }) + + it('feature status should be not entitled if no account or offline repo', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false) + + featuresService['completedSuccessfulFeaturesRetrieval'] = false + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe( + FeatureStatus.NoUserSubscription, + ) + }) + + it('feature status should be entitled for subscriber until first successful features request made if no cached features', async () => { + const featuresService = createService() + + apiService.getUserFeatures = jest.fn().mockReturnValue({ + data: { + features: [], + }, + }) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true) + + featuresService['completedSuccessfulFeaturesRetrieval'] = false + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.Entitled) + + await featuresService.didDownloadFeatures(features) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan) + }) + + it('feature status should be dynamic for subscriber if cached features and no successful features request made yet', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + featuresService['completedSuccessfulFeaturesRetrieval'] = false + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan) + + featuresService['completedSuccessfulFeaturesRetrieval'] = false + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan) + }) + + it('feature status for offline subscription', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false) + featuresService.hasOnlineSubscription = jest.fn().mockReturnValue(false) + featuresService['completedSuccessfulFeaturesRetrieval'] = true + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.NoUserSubscription) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe( + FeatureStatus.NoUserSubscription, + ) + + featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.MidnightTheme)).toBe(FeatureStatus.Entitled) + expect(featuresService.getFeatureStatus(FeatureIdentifier.TokenVaultEditor)).toBe(FeatureStatus.NotInCurrentPlan) + }) + + it('feature status for deprecated feature', async () => { + const featuresService = createService() + + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe( + FeatureStatus.NoUserSubscription, + ) + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + expect(featuresService.getFeatureStatus(FeatureIdentifier.DeprecatedFileSafe as FeatureIdentifier)).toBe( + FeatureStatus.Entitled, + ) + }) + + it('has paid subscription', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(true) + + expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toBeFalsy + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser, RoleName.PlusUser]) + + expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toEqual(true) + }) + + it('has paid subscription should be true if offline repo and signed into third party server', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + + featuresService.hasOfflineRepo = jest.fn().mockReturnValue(true) + sessionManager.isSignedIntoFirstPartyServer = jest.fn().mockReturnValue(false) + + expect(featuresService.hasPaidOnlineOrOfflineSubscription()).toEqual(true) + }) + }) + + describe('migrateFeatureRepoToUserSetting', () => { + it('should extract key from extension repo url and update user setting', async () => { + const extensionKey = '129b029707e3470c94a8477a437f9394' + const extensionRepoItem = new SNFeatureRepo({ + uuid: '456', + content_type: ContentType.ExtensionRepo, + content: { + url: `https://extensions.standardnotes.org/${extensionKey}`, + }, + } as never) + + const featuresService = createService() + await featuresService.migrateFeatureRepoToUserSetting([extensionRepoItem]) + expect(settingsService.updateSetting).toHaveBeenCalledWith(SettingName.ExtensionKey, extensionKey, true) + }) + }) + + describe('downloadExternalFeature', () => { + it('should not allow if identifier matches native identifier', async () => { + apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ + data: { + identifier: 'org.standardnotes.bold-editor', + name: 'Bold Editor', + content_type: 'SN|Component', + area: 'editor-editor', + version: '1.0.0', + url: 'http://localhost:8005/', + }, + }) + + const installUrl = 'http://example.com' + crypto.base64Decode = jest.fn().mockReturnValue(installUrl) + + const featuresService = createService() + const result = await featuresService.downloadExternalFeature(installUrl) + expect(result).toBeUndefined() + }) + + it('should not allow if url matches native url', async () => { + apiService.downloadFeatureUrl = jest.fn().mockReturnValue({ + data: { + identifier: 'org.foo.bar', + name: 'Bold Editor', + content_type: 'SN|Component', + area: 'editor-editor', + version: '1.0.0', + url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html', + }, + }) + + const installUrl = 'http://example.com' + crypto.base64Decode = jest.fn().mockReturnValue(installUrl) + + const featuresService = createService() + const result = await featuresService.downloadExternalFeature(installUrl) + expect(result).toBeUndefined() + }) + }) + + describe('sortRolesByHierarchy', () => { + it('should sort given roles according to role hierarchy', () => { + const featuresService = createService() + + const sortedRoles = featuresService.rolesBySorting([RoleName.ProUser, RoleName.CoreUser, RoleName.PlusUser]) + + expect(sortedRoles).toStrictEqual([RoleName.CoreUser, RoleName.PlusUser, RoleName.ProUser]) + }) + }) + + describe('hasMinimumRole', () => { + it('should be false if core user checks for plus role', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.CoreUser]) + + const hasPlusUserRole = featuresService.hasMinimumRole(RoleName.PlusUser) + + expect(hasPlusUserRole).toBe(false) + }) + + it('should be false if plus user checks for pro role', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.PlusUser, RoleName.CoreUser]) + + const hasProUserRole = featuresService.hasMinimumRole(RoleName.ProUser) + + expect(hasProUserRole).toBe(false) + }) + + it('should be true if pro user checks for core user', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.ProUser, RoleName.PlusUser]) + + const hasCoreUserRole = featuresService.hasMinimumRole(RoleName.CoreUser) + + expect(hasCoreUserRole).toBe(true) + }) + + it('should be true if pro user checks for pro user', async () => { + const featuresService = createService() + + await featuresService.updateRolesAndFetchFeatures('123', [RoleName.ProUser, RoleName.PlusUser]) + + const hasProUserRole = featuresService.hasMinimumRole(RoleName.ProUser) + + expect(hasProUserRole).toBe(true) + }) + }) +}) diff --git a/packages/snjs/lib/Services/Features/FeaturesService.ts b/packages/snjs/lib/Services/Features/FeaturesService.ts new file mode 100644 index 000000000..52e325567 --- /dev/null +++ b/packages/snjs/lib/Services/Features/FeaturesService.ts @@ -0,0 +1,718 @@ +import { AccountEvent, UserService } from '../User/UserService' +import { SNApiService } from '../Api/ApiService' +import { + arraysEqual, + convertTimestampToMilliseconds, + removeFromArray, + Copy, + lastElement, + isString, +} from '@standardnotes/utils' +import { ClientDisplayableError, UserFeaturesResponse } from '@standardnotes/responses' +import { ContentType, RoleName } from '@standardnotes/common' +import { FeaturesClientInterface } from './ClientInterface' +import { FillItemContent, PayloadEmitSource } from '@standardnotes/models' +import { ItemManager } from '../Items/ItemManager' +import { LEGACY_PROD_EXT_ORIGIN, PROD_OFFLINE_FEATURES_URL } from '../../Hosts' +import { SettingName } from '@standardnotes/settings' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNSessionManager } from '@Lib/Services/Session/SessionManager' +import { SNSettingsService } from '../Settings' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { SNSyncService } from '../Sync/SyncService' +import { SNWebSocketsService, WebSocketsServiceEvent } from '../Api/WebsocketsService' +import { TRUSTED_CUSTOM_EXTENSIONS_HOSTS, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts' +import { UserRolesChangedEvent } from '@standardnotes/domain-events' +import { UuidString } from '@Lib/Types/UuidString' +import * as FeaturesImports from '@standardnotes/features' +import * as Messages from '@Lib/Services/Api/Messages' +import * as Models from '@standardnotes/models' +import * as Services from '@standardnotes/services' +import { + FeaturesEvent, + FeatureStatus, + OfflineSubscriptionEntitlements, + SetOfflineFeaturesFunctionResponse, +} from './Types' +import { DiagnosticInfo } from '@standardnotes/services' + +type GetOfflineSubscriptionDetailsResponse = OfflineSubscriptionEntitlements | ClientDisplayableError + +export class SNFeaturesService + extends Services.AbstractService + implements FeaturesClientInterface, Services.InternalEventHandlerInterface +{ + private deinited = false + private roles: RoleName[] = [] + private features: FeaturesImports.FeatureDescription[] = [] + private enabledExperimentalFeatures: FeaturesImports.FeatureIdentifier[] = [] + private removeWebSocketsServiceObserver: () => void + private removefeatureReposObserver: () => void + private removeSignInObserver: () => void + private needsInitialFeaturesUpdate = true + private completedSuccessfulFeaturesRetrieval = false + + constructor( + private storageService: DiskStorageService, + private apiService: SNApiService, + private itemManager: ItemManager, + private webSocketsService: SNWebSocketsService, + private settingsService: SNSettingsService, + private userService: UserService, + private syncService: SNSyncService, + private alertService: Services.AlertService, + private sessionManager: SNSessionManager, + private crypto: PureCryptoInterface, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + + this.removeWebSocketsServiceObserver = webSocketsService.addEventObserver(async (eventName, data) => { + if (eventName === WebSocketsServiceEvent.UserRoleMessageReceived) { + const { + payload: { userUuid, currentRoles }, + } = data as UserRolesChangedEvent + await this.updateRolesAndFetchFeatures(userUuid, currentRoles) + } + }) + + this.removefeatureReposObserver = this.itemManager.addObserver( + ContentType.ExtensionRepo, + async ({ changed, inserted, source }) => { + const sources = [ + PayloadEmitSource.InitialObserverRegistrationPush, + PayloadEmitSource.LocalInserted, + PayloadEmitSource.LocalDatabaseLoaded, + PayloadEmitSource.RemoteRetrieved, + PayloadEmitSource.FileImport, + ] + + if (sources.includes(source)) { + const items = [...changed, ...inserted] as Models.SNFeatureRepo[] + if (this.sessionManager.isSignedIntoFirstPartyServer()) { + await this.migrateFeatureRepoToUserSetting(items) + } else { + await this.migrateFeatureRepoToOfflineEntitlements(items) + } + } + }, + ) + + this.removeSignInObserver = this.userService.addEventObserver((eventName: AccountEvent) => { + if (eventName === AccountEvent.SignedInOrRegistered) { + const featureRepos = this.itemManager.getItems(ContentType.ExtensionRepo) as Models.SNFeatureRepo[] + + if (!this.apiService.isThirdPartyHostUsed()) { + void this.migrateFeatureRepoToUserSetting(featureRepos) + } + } + }) + } + + async handleEvent(event: Services.InternalEventInterface): Promise { + if (event.type === Services.ApiServiceEvent.MetaReceived) { + if (!this.syncService) { + this.log('[Features Service] Handling events interrupted. Sync service is not yet initialized.', event) + + return + } + + /** + * All user data must be downloaded before we map features. Otherwise, feature mapping + * may think a component doesn't exist and create a new one, when in reality the component + * already exists but hasn't been downloaded yet. + */ + if (!this.syncService.completedOnlineDownloadFirstSync) { + return + } + + const { userUuid, userRoles } = event.payload as Services.MetaReceivedData + await this.updateRolesAndFetchFeatures( + userUuid, + userRoles.map((role) => role.name), + ) + } + } + + override async handleApplicationStage(stage: Services.ApplicationStage): Promise { + await super.handleApplicationStage(stage) + if (stage === Services.ApplicationStage.FullSyncCompleted_13) { + if (!this.hasOnlineSubscription()) { + const offlineRepo = this.getOfflineRepo() + if (offlineRepo) { + void this.downloadOfflineFeatures(offlineRepo) + } + } + } + } + + public enableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void { + const feature = this.getUserFeature(identifier) + if (!feature) { + throw Error('Attempting to enable a feature user does not have access to.') + } + + this.enabledExperimentalFeatures.push(identifier) + + void this.storageService.setValue(Services.StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures) + + void this.mapRemoteNativeFeaturesToItems([feature]) + void this.notifyEvent(FeaturesEvent.FeaturesUpdated) + } + + public disableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void { + const feature = this.getUserFeature(identifier) + if (!feature) { + throw Error('Attempting to disable a feature user does not have access to.') + } + + removeFromArray(this.enabledExperimentalFeatures, identifier) + + void this.storageService.setValue(Services.StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures) + + const component = this.itemManager + .getItems([ContentType.Component, ContentType.Theme]) + .find((component) => component.identifier === identifier) + if (!component) { + return + } + + void this.itemManager.setItemToBeDeleted(component).then(() => { + void this.syncService.sync() + }) + void this.notifyEvent(FeaturesEvent.FeaturesUpdated) + } + + public toggleExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void { + if (this.isExperimentalFeatureEnabled(identifier)) { + this.disableExperimentalFeature(identifier) + } else { + this.enableExperimentalFeature(identifier) + } + } + + public getExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] { + return FeaturesImports.ExperimentalFeatures + } + + public isExperimentalFeature(featureId: FeaturesImports.FeatureIdentifier): boolean { + return this.getExperimentalFeatures().includes(featureId) + } + + public getEnabledExperimentalFeatures(): FeaturesImports.FeatureIdentifier[] { + return this.enabledExperimentalFeatures + } + + public isExperimentalFeatureEnabled(featureId: FeaturesImports.FeatureIdentifier): boolean { + return this.enabledExperimentalFeatures.includes(featureId) + } + + public async setOfflineFeaturesCode(code: string): Promise { + try { + const activationCodeWithoutSpaces = code.replace(/\s/g, '') + const decodedData = this.crypto.base64Decode(activationCodeWithoutSpaces) + const result = this.parseOfflineEntitlementsCode(decodedData) + + if (result instanceof ClientDisplayableError) { + return result + } + + const offlineRepo = (await this.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + offlineFeaturesUrl: result.featuresUrl, + offlineKey: result.extensionKey, + migratedToOfflineEntitlements: true, + } as Models.FeatureRepoContent), + true, + )) as Models.SNFeatureRepo + void this.syncService.sync() + return this.downloadOfflineFeatures(offlineRepo) + } catch (err) { + return new ClientDisplayableError(Messages.API_MESSAGE_FAILED_OFFLINE_ACTIVATION) + } + } + + private getOfflineRepo(): Models.SNFeatureRepo | undefined { + const repos = this.itemManager.getItems(ContentType.ExtensionRepo) as Models.SNFeatureRepo[] + return repos.filter((repo) => repo.migratedToOfflineEntitlements)[0] + } + + public hasOfflineRepo(): boolean { + return this.getOfflineRepo() != undefined + } + + public async deleteOfflineFeatureRepo(): Promise { + const repo = this.getOfflineRepo() + if (repo) { + await this.itemManager.setItemToBeDeleted(repo) + void this.syncService.sync() + } + await this.storageService.removeValue(Services.StorageKey.UserFeatures) + } + + private parseOfflineEntitlementsCode(code: string): GetOfflineSubscriptionDetailsResponse | ClientDisplayableError { + try { + const { featuresUrl, extensionKey } = JSON.parse(code) + return { + featuresUrl, + extensionKey, + } + } catch (error) { + return new ClientDisplayableError(Messages.API_MESSAGE_FAILED_OFFLINE_ACTIVATION) + } + } + + private async downloadOfflineFeatures( + repo: Models.SNFeatureRepo, + ): Promise { + const result = await this.apiService.downloadOfflineFeaturesFromRepo(repo) + if (result instanceof ClientDisplayableError) { + return result + } + await this.didDownloadFeatures(result.features) + return undefined + } + + public async migrateFeatureRepoToUserSetting(featureRepos: Models.SNFeatureRepo[] = []): Promise { + for (const item of featureRepos) { + if (item.migratedToUserSetting) { + continue + } + if (item.onlineUrl) { + const repoUrl: string = item.onlineUrl + const userKeyMatch = repoUrl.match(/\w{32,64}/) + if (userKeyMatch && userKeyMatch.length > 0) { + const userKey = userKeyMatch[0] + await this.settingsService.updateSetting(SettingName.ExtensionKey, userKey, true) + await this.itemManager.changeFeatureRepo(item, (m) => { + m.migratedToUserSetting = true + }) + } + } + } + } + + public async migrateFeatureRepoToOfflineEntitlements(featureRepos: Models.SNFeatureRepo[] = []): Promise { + for (const item of featureRepos) { + if (item.migratedToOfflineEntitlements) { + continue + } + + if (item.onlineUrl) { + const repoUrl = item.onlineUrl + const { origin } = new URL(repoUrl) + + if (!origin.includes(LEGACY_PROD_EXT_ORIGIN)) { + continue + } + + const userKeyMatch = repoUrl.match(/\w{32,64}/) + if (userKeyMatch && userKeyMatch.length > 0) { + const userKey = userKeyMatch[0] + const updatedRepo = await this.itemManager.changeFeatureRepo(item, (m) => { + m.offlineFeaturesUrl = PROD_OFFLINE_FEATURES_URL + m.offlineKey = userKey + m.migratedToOfflineEntitlements = true + }) + await this.downloadOfflineFeatures(updatedRepo) + } + } + } + } + + public initializeFromDisk(): void { + this.roles = this.storageService.getValue(Services.StorageKey.UserRoles, undefined, []) + + this.features = this.storageService.getValue(Services.StorageKey.UserFeatures, undefined, []) + + this.enabledExperimentalFeatures = this.storageService.getValue( + Services.StorageKey.ExperimentalFeatures, + undefined, + [], + ) + } + + public async updateRolesAndFetchFeatures(userUuid: UuidString, roles: RoleName[]): Promise { + const userRolesChanged = this.haveRolesChanged(roles) + + if (!userRolesChanged && !this.needsInitialFeaturesUpdate) { + return + } + + this.needsInitialFeaturesUpdate = false + + await this.setRoles(roles) + + const shouldDownloadRoleBasedFeatures = !this.hasOfflineRepo() + + if (shouldDownloadRoleBasedFeatures) { + const featuresResponse = await this.apiService.getUserFeatures(userUuid) + + if (!featuresResponse.error && featuresResponse.data && !this.deinited) { + const features = (featuresResponse as UserFeaturesResponse).data.features + await this.didDownloadFeatures(features) + } + } + } + + private async setRoles(roles: RoleName[]): Promise { + this.roles = roles + if (!arraysEqual(this.roles, roles)) { + void this.notifyEvent(FeaturesEvent.UserRolesChanged) + } + await this.storageService.setValue(Services.StorageKey.UserRoles, this.roles) + } + + public async didDownloadFeatures(features: FeaturesImports.FeatureDescription[]): Promise { + features = features + .filter((feature) => !!FeaturesImports.FindNativeFeature(feature.identifier)) + .map((feature) => this.mapRemoteNativeFeatureToStaticFeature(feature)) + + this.features = features + this.completedSuccessfulFeaturesRetrieval = true + void this.notifyEvent(FeaturesEvent.FeaturesUpdated) + void this.storageService.setValue(Services.StorageKey.UserFeatures, this.features) + + await this.mapRemoteNativeFeaturesToItems(features) + } + + public isThirdPartyFeature(identifier: string): boolean { + const isNativeFeature = !!FeaturesImports.FindNativeFeature(identifier as FeaturesImports.FeatureIdentifier) + return !isNativeFeature + } + + private mapRemoteNativeFeatureToStaticFeature( + remoteFeature: FeaturesImports.FeatureDescription, + ): FeaturesImports.FeatureDescription { + const remoteFields: (keyof FeaturesImports.FeatureDescription)[] = [ + 'expires_at', + 'role_name', + 'no_expire', + 'permission_name', + ] + + const nativeFeature = FeaturesImports.FindNativeFeature(remoteFeature.identifier) + if (!nativeFeature) { + throw Error(`Attempting to map remote native to unfound static feature ${remoteFeature.identifier}`) + } + + const nativeFeatureCopy = Copy(nativeFeature) as FeaturesImports.FeatureDescription + + for (const field of remoteFields) { + nativeFeatureCopy[field] = remoteFeature[field] as never + } + + if (nativeFeatureCopy.expires_at) { + nativeFeatureCopy.expires_at = convertTimestampToMilliseconds(nativeFeatureCopy.expires_at) + } + return nativeFeatureCopy + } + + public getUserFeature(featureId: FeaturesImports.FeatureIdentifier): FeaturesImports.FeatureDescription | undefined { + return this.features.find((feature) => feature.identifier === featureId) + } + + hasOnlineSubscription(): boolean { + const roles = this.roles + const unpaidRoles = [RoleName.CoreUser] + return roles.some((role) => !unpaidRoles.includes(role)) + } + + public hasPaidOnlineOrOfflineSubscription(): boolean { + return this.hasOnlineSubscription() || this.hasOfflineRepo() + } + + public rolesBySorting(roles: RoleName[]): RoleName[] { + return Object.values(RoleName).filter((role) => roles.includes(role)) + } + + public hasMinimumRole(role: RoleName): boolean { + const sortedAllRoles = Object.values(RoleName) + + const sortedUserRoles = this.rolesBySorting(this.roles) + + const highestUserRoleIndex = sortedAllRoles.indexOf(lastElement(sortedUserRoles) as RoleName) + + const indexOfRoleToCheck = sortedAllRoles.indexOf(role) + + return indexOfRoleToCheck <= highestUserRoleIndex + } + + public isFeatureDeprecated(featureId: FeaturesImports.FeatureIdentifier): boolean { + return FeaturesImports.FindNativeFeature(featureId)?.deprecated === true + } + + public getFeatureStatus(featureId: FeaturesImports.FeatureIdentifier): FeatureStatus { + const isDeprecated = this.isFeatureDeprecated(featureId) + if (isDeprecated) { + if (this.hasPaidOnlineOrOfflineSubscription()) { + return FeatureStatus.Entitled + } else { + return FeatureStatus.NoUserSubscription + } + } + + const isThirdParty = FeaturesImports.FindNativeFeature(featureId) == undefined + if (isThirdParty) { + const component = this.itemManager + .getDisplayableComponents() + .find((candidate) => candidate.identifier === featureId) + if (!component) { + return FeatureStatus.NoUserSubscription + } + if (component.isExpired) { + return FeatureStatus.InCurrentPlanButExpired + } + return FeatureStatus.Entitled + } + + if (this.hasPaidOnlineOrOfflineSubscription()) { + if (!this.completedSuccessfulFeaturesRetrieval) { + const hasCachedFeatures = this.features.length > 0 + const temporarilyAllowUntilServerUpdates = !hasCachedFeatures + if (temporarilyAllowUntilServerUpdates) { + return FeatureStatus.Entitled + } + } + } else { + return FeatureStatus.NoUserSubscription + } + + const feature = this.getUserFeature(featureId) + if (!feature) { + return FeatureStatus.NotInCurrentPlan + } + + const expired = feature.expires_at && new Date(feature.expires_at).getTime() < new Date().getTime() + if (expired) { + if (!this.roles.includes(feature.role_name as RoleName)) { + return FeatureStatus.NotInCurrentPlan + } else { + return FeatureStatus.InCurrentPlanButExpired + } + } + + return FeatureStatus.Entitled + } + + private haveRolesChanged(roles: RoleName[]): boolean { + return roles.some((role) => !this.roles.includes(role)) || this.roles.some((role) => !roles.includes(role)) + } + + private componentContentForNativeFeatureDescription(feature: FeaturesImports.FeatureDescription): Models.ItemContent { + const componentContent: Partial = { + area: feature.area, + name: feature.name, + package_info: feature, + valid_until: new Date(feature.expires_at || 0), + } + return FillItemContent(componentContent) + } + + private async mapRemoteNativeFeaturesToItems(features: FeaturesImports.FeatureDescription[]): Promise { + const currentItems = this.itemManager.getItems([ContentType.Component, ContentType.Theme]) + const itemsToDelete: Models.SNComponent[] = [] + let hasChanges = false + + for (const feature of features) { + const didChange = await this.mapNativeFeatureToItem(feature, currentItems, itemsToDelete) + if (didChange) { + hasChanges = true + } + } + + await this.itemManager.setItemsToBeDeleted(itemsToDelete) + if (hasChanges) { + void this.syncService.sync() + } + } + + private async mapNativeFeatureToItem( + feature: FeaturesImports.FeatureDescription, + currentItems: Models.SNComponent[], + itemsToDelete: Models.SNComponent[], + ): Promise { + if (!feature.content_type) { + return false + } + + if (this.isExperimentalFeature(feature.identifier) && !this.isExperimentalFeatureEnabled(feature.identifier)) { + return false + } + + let hasChanges = false + const now = new Date() + const expired = new Date(feature.expires_at || 0).getTime() < now.getTime() + + const existingItem = currentItems.find((item) => { + if (item.content.package_info) { + const itemIdentifier = item.content.package_info.identifier + return itemIdentifier === feature.identifier + } + return false + }) + + if (feature.deprecated && !existingItem) { + return false + } + + let resultingItem: Models.SNComponent | undefined = existingItem + + if (existingItem) { + const featureExpiresAt = new Date(feature.expires_at || 0) + const hasChange = + JSON.stringify(feature) !== JSON.stringify(existingItem.package_info) || + featureExpiresAt.getTime() !== existingItem.valid_until.getTime() + if (hasChange) { + resultingItem = await this.itemManager.changeComponent(existingItem, (mutator) => { + mutator.package_info = feature + mutator.valid_until = featureExpiresAt + }) + hasChanges = true + } else { + resultingItem = existingItem + } + } else if (!expired || feature.content_type === ContentType.Component) { + resultingItem = (await this.itemManager.createItem( + feature.content_type, + this.componentContentForNativeFeatureDescription(feature), + true, + )) as Models.SNComponent + hasChanges = true + } + + if (expired && resultingItem) { + if (feature.content_type !== ContentType.Component) { + itemsToDelete.push(resultingItem) + hasChanges = true + } + } + + return hasChanges + } + + public async downloadExternalFeature(urlOrCode: string): Promise { + let url = urlOrCode + try { + url = this.crypto.base64Decode(urlOrCode) + // eslint-disable-next-line no-empty + } catch (err) {} + + try { + const trustedCustomExtensionsUrls = [...TRUSTED_FEATURE_HOSTS, ...TRUSTED_CUSTOM_EXTENSIONS_HOSTS] + const { host } = new URL(url) + if (!trustedCustomExtensionsUrls.includes(host)) { + const didConfirm = await this.alertService.confirm( + Messages.API_MESSAGE_UNTRUSTED_EXTENSIONS_WARNING, + 'Install extension from an untrusted source?', + 'Proceed to install', + Services.ButtonType.Danger, + 'Cancel', + ) + if (didConfirm) { + return this.performDownloadExternalFeature(url) + } + } else { + return this.performDownloadExternalFeature(url) + } + } catch (err) { + void this.alertService.alert(Messages.INVALID_EXTENSION_URL) + } + + return undefined + } + + private async performDownloadExternalFeature(url: string): Promise { + const response = await this.apiService.downloadFeatureUrl(url) + if (response.error) { + await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION) + return undefined + } + + let rawFeature = response.data as FeaturesImports.ThirdPartyFeatureDescription + + if (isString(rawFeature)) { + try { + rawFeature = JSON.parse(rawFeature) + // eslint-disable-next-line no-empty + } catch (error) {} + } + + if (!rawFeature.content_type) { + return + } + + const isValidContentType = [ + ContentType.Component, + ContentType.Theme, + ContentType.ActionsExtension, + ContentType.ExtensionRepo, + ].includes(rawFeature.content_type) + + if (!isValidContentType) { + return + } + + const nativeFeature = FeaturesImports.FindNativeFeature(rawFeature.identifier) + if (nativeFeature) { + await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION) + return + } + + if (rawFeature.url) { + for (const nativeFeature of FeaturesImports.GetFeatures()) { + if (rawFeature.url.includes(nativeFeature.identifier)) { + await this.alertService.alert(Messages.API_MESSAGE_FAILED_DOWNLOADING_EXTENSION) + return + } + } + } + + const content = FillItemContent({ + area: rawFeature.area, + name: rawFeature.name, + package_info: rawFeature, + valid_until: new Date(rawFeature.expires_at || 0), + hosted_url: rawFeature.url, + } as Partial) + + const component = this.itemManager.createTemplateItem(rawFeature.content_type, content) as Models.SNComponent + + return component + } + + override deinit(): void { + super.deinit() + this.removeSignInObserver() + ;(this.removeSignInObserver as unknown) = undefined + this.removeWebSocketsServiceObserver() + ;(this.removeWebSocketsServiceObserver as unknown) = undefined + this.removefeatureReposObserver() + ;(this.removefeatureReposObserver as unknown) = undefined + ;(this.roles as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.apiService as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.webSocketsService as unknown) = undefined + ;(this.settingsService as unknown) = undefined + ;(this.userService as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.sessionManager as unknown) = undefined + ;(this.crypto as unknown) = undefined + this.deinited = true + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + features: { + roles: this.roles, + features: this.features, + enabledExperimentalFeatures: this.enabledExperimentalFeatures, + needsInitialFeaturesUpdate: this.needsInitialFeaturesUpdate, + completedSuccessfulFeaturesRetrieval: this.completedSuccessfulFeaturesRetrieval, + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Features/Types.ts b/packages/snjs/lib/Services/Features/Types.ts new file mode 100644 index 000000000..f455af7c3 --- /dev/null +++ b/packages/snjs/lib/Services/Features/Types.ts @@ -0,0 +1,20 @@ +import { ClientDisplayableError } from '@standardnotes/responses' + +export type SetOfflineFeaturesFunctionResponse = ClientDisplayableError | undefined + +export type OfflineSubscriptionEntitlements = { + featuresUrl: string + extensionKey: string +} + +export enum FeaturesEvent { + UserRolesChanged = 'UserRolesChanged', + FeaturesUpdated = 'FeaturesUpdated', +} + +export enum FeatureStatus { + NoUserSubscription = 'NoUserSubscription', + NotInCurrentPlan = 'NotInCurrentPlan', + InCurrentPlanButExpired = 'InCurrentPlanButExpired', + Entitled = 'Entitled', +} diff --git a/packages/snjs/lib/Services/Features/index.ts b/packages/snjs/lib/Services/Features/index.ts new file mode 100644 index 000000000..a2c4fe199 --- /dev/null +++ b/packages/snjs/lib/Services/Features/index.ts @@ -0,0 +1,3 @@ +export * from './ClientInterface' +export * from './FeaturesService' +export * from './Types' diff --git a/packages/snjs/lib/Services/History/HistoryManager.ts b/packages/snjs/lib/Services/History/HistoryManager.ts new file mode 100644 index 000000000..d59ff37c4 --- /dev/null +++ b/packages/snjs/lib/Services/History/HistoryManager.ts @@ -0,0 +1,249 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { EncryptionService } from '@standardnotes/encryption' +import { isNullOrUndefined, removeFromArray } from '@standardnotes/utils' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { SNApiService } from '@Lib/Services/Api/ApiService' +import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService' +import { UuidString } from '../../Types/UuidString' +import * as Models from '@standardnotes/models' +import * as Responses from '@standardnotes/responses' +import * as Services from '@standardnotes/services' +import { isErrorDecryptingPayload, PayloadTimestampDefaults, SNNote } from '@standardnotes/models' + +/** The amount of revisions per item above which should call for an optimization. */ +const DefaultItemRevisionsThreshold = 20 + +/** + * The amount of characters added or removed that + * constitute a keepable entry after optimization. + */ +const LargeEntryDeltaThreshold = 25 + +/** + * The history manager is responsible for: + * 1. Transient session history, which include keeping track of changes made in the + * current application session. These change logs (unless otherwise configured) are + * ephemeral and do not persist past application restart. Session history entries are + * added via change observers that trigger when an item changes. + * 2. Remote server history. Entries are automatically added by the server and must be + * retrieved per item via an API call. + */ +export class SNHistoryManager extends Services.AbstractService { + private removeChangeObserver: () => void + + /** + * When no history exists for an item yet, we first put it in the staging map. + * Then, the next time the item changes and it has no history, we check the staging map. + * If the entry from the staging map differs from the incoming change, we now add the incoming + * change to the history map and remove it from staging. This is a way to detect when the first + * actual change of an item occurs (especially new items), rather than tracking a change + * as an item propagating through the different PayloadSource + * lifecycles (created, local saved, presyncsave, etc) + */ + private historyStaging: Partial> = {} + private history: Models.HistoryMap = {} + private itemRevisionThreshold = DefaultItemRevisionsThreshold + + constructor( + private itemManager: ItemManager, + private storageService: DiskStorageService, + private apiService: SNApiService, + private protocolService: EncryptionService, + public deviceInterface: Services.DeviceInterface, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + this.removeChangeObserver = this.itemManager.addObserver(ContentType.Note, ({ changed, inserted }) => { + this.recordNewHistoryForItems(changed.concat(inserted) as SNNote[]) + }) + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.history as unknown) = undefined + if (this.removeChangeObserver) { + this.removeChangeObserver() + ;(this.removeChangeObserver as unknown) = undefined + } + super.deinit() + } + + private recordNewHistoryForItems(items: Models.SNNote[]) { + for (const item of items) { + const itemHistory = this.history[item.uuid] || [] + const latestEntry = Models.historyMapFunctions.getNewestRevision(itemHistory) + const historyPayload = new Models.DecryptedPayload(item.payload) + + const currentValueEntry = Models.CreateHistoryEntryForPayload(historyPayload, latestEntry) + if (currentValueEntry.isDiscardable()) { + continue + } + + /** + * For every change that comes in, first add it to the staging area. + * Then, only on the next subsequent change do we add this previously + * staged entry + */ + const stagedEntry = this.historyStaging[item.uuid] + + /** Add prospective to staging, and consider now adding previously staged as new revision */ + this.historyStaging[item.uuid] = currentValueEntry + + if (!stagedEntry) { + continue + } + + if (stagedEntry.isSameAsEntry(currentValueEntry)) { + continue + } + + if (latestEntry && stagedEntry.isSameAsEntry(latestEntry)) { + continue + } + + itemHistory.unshift(stagedEntry) + this.history[item.uuid] = itemHistory + + this.optimizeHistoryForItem(item.uuid) + } + } + + sessionHistoryForItem(item: Models.SNNote): Models.HistoryEntry[] { + return this.history[item.uuid] || [] + } + + getHistoryMapCopy(): Models.HistoryMap { + const copy = Object.assign({}, this.history) + for (const [key, value] of Object.entries(copy)) { + copy[key] = value.slice() + } + return Object.freeze(copy) + } + + /** + * Fetches a list of revisions from the server for an item. These revisions do not + * include the item's content. Instead, each revision's content must be fetched + * individually upon selection via `fetchRemoteRevision`. + */ + async remoteHistoryForItem(item: Models.SNNote): Promise { + const response = await this.apiService.getItemRevisions(item.uuid) + if (response.error || isNullOrUndefined(response.data)) { + return undefined + } + return (response as Responses.RevisionListResponse).data + } + + /** + * Expands on a revision fetched via `remoteHistoryForItem` by getting a revision's + * complete fields (including encrypted content). + */ + async fetchRemoteRevision( + note: Models.SNNote, + entry: Responses.RevisionListEntry, + ): Promise { + const revisionResponse = await this.apiService.getRevision(entry, note.uuid) + if (revisionResponse.error || isNullOrUndefined(revisionResponse.data)) { + return undefined + } + const revision = (revisionResponse as Responses.SingleRevisionResponse).data + + const serverPayload = new Models.EncryptedPayload({ + ...PayloadTimestampDefaults(), + ...revision, + updated_at: new Date(revision.updated_at), + created_at: new Date(revision.created_at), + waitingForKey: false, + errorDecrypting: false, + }) + + /** + * When an item is duplicated, its revisions also carry over to the newly created item. + * However since the new item has a different UUID than the source item, we must decrypt + * these olders revisions (which have not been mutated after copy) with the source item's + * uuid. + */ + const embeddedParams = this.protocolService.getEmbeddedPayloadAuthenticatedData(serverPayload) + const sourceItemUuid = embeddedParams?.u as Uuid | undefined + + const payload = serverPayload.copy({ + uuid: sourceItemUuid || revision.item_uuid, + }) + + if (!Models.isRemotePayloadAllowed(payload)) { + console.error('Remote payload is disallowed', payload) + return undefined + } + + const encryptedPayload = new Models.EncryptedPayload(payload) + + const decryptedPayload = await this.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { items: [encryptedPayload] }, + }) + + if (isErrorDecryptingPayload(decryptedPayload)) { + return undefined + } + + return new Models.HistoryEntry(decryptedPayload) + } + + async deleteRemoteRevision(note: SNNote, entry: Responses.RevisionListEntry): Promise { + const response = await this.apiService.deleteRevision(note.uuid, entry) + return response + } + + /** + * Clean up if there are too many revisions. Note itemRevisionThreshold + * is the amount of revisions which above, call for an optimization. An + * optimization may not remove entries above this threshold. It will + * determine what it should keep and what it shouldn't. So, it is possible + * to have a threshold of 60 but have 600 entries, if the item history deems + * those worth keeping. + * + * Rules: + * - Keep an entry if it is the oldest entry + * - Keep an entry if it is the latest entry + * - Keep an entry if it is Significant + * - If an entry is Significant and it is a deletion change, keep the entry before this entry. + */ + optimizeHistoryForItem(uuid: string): void { + const entries = this.history[uuid] || [] + if (entries.length <= this.itemRevisionThreshold) { + return + } + + const isEntrySignificant = (entry: Models.HistoryEntry) => { + return entry.deltaSize() > LargeEntryDeltaThreshold + } + const keepEntries: Models.HistoryEntry[] = [] + const processEntry = (entry: Models.HistoryEntry, index: number, keep: boolean) => { + /** + * Entries may be processed retrospectively, meaning it can be + * decided to be deleted, then an upcoming processing can change that. + */ + if (keep) { + keepEntries.unshift(entry) + if (isEntrySignificant(entry) && entry.operationVector() === -1) { + /** This is a large negative change. Hang on to the previous entry. */ + const previousEntry = entries[index + 1] + if (previousEntry) { + keepEntries.unshift(previousEntry) + } + } + } else { + /** Don't keep, remove if in keep */ + removeFromArray(keepEntries, entry) + } + } + for (let index = entries.length - 1; index >= 0; index--) { + const entry = entries[index] + const isSignificant = index === 0 || index === entries.length - 1 || isEntrySignificant(entry) + processEntry(entry, index, isSignificant) + } + const filtered = entries.filter((entry) => { + return keepEntries.includes(entry) + }) + this.history[uuid] = filtered + } +} diff --git a/packages/snjs/lib/Services/History/index.ts b/packages/snjs/lib/Services/History/index.ts new file mode 100644 index 000000000..b4400c806 --- /dev/null +++ b/packages/snjs/lib/Services/History/index.ts @@ -0,0 +1 @@ +export * from './HistoryManager' diff --git a/packages/snjs/lib/Services/Items/ItemManager.spec.ts b/packages/snjs/lib/Services/Items/ItemManager.spec.ts new file mode 100644 index 000000000..4baa1755f --- /dev/null +++ b/packages/snjs/lib/Services/Items/ItemManager.spec.ts @@ -0,0 +1,742 @@ +import { ContentType } from '@standardnotes/common' +import { InternalEventBusInterface } from '@standardnotes/services' +import { ItemManager } from './ItemManager' +import { PayloadManager } from '../Payloads/PayloadManager' +import { UuidGenerator } from '@standardnotes/utils' +import * as Models from '@standardnotes/models' +import { + DecryptedPayload, + DeletedPayload, + EncryptedPayload, + FillItemContent, + PayloadTimestampDefaults, + NoteContent, +} from '@standardnotes/models' + +const setupRandomUuid = () => { + UuidGenerator.SetGenerator(() => String(Math.random())) +} + +const VIEW_NOT_PINNED = '!["Not Pinned", "pinned", "=", false]' +const VIEW_LAST_DAY = '!["Last Day", "updated_at", ">", "1.days.ago"]' +const VIEW_LONG = '!["Long", "text.length", ">", 500]' + +const NotPinnedPredicate = Models.predicateFromJson({ + keypath: 'pinned', + operator: '=', + value: false, +}) + +const LastDayPredicate = Models.predicateFromJson({ + keypath: 'updated_at', + operator: '>', + value: '1.days.ago', +}) + +const LongTextPredicate = Models.predicateFromJson({ + keypath: 'text.length' as never, + operator: '>', + value: 500, +}) + +describe('itemManager', () => { + let payloadManager: PayloadManager + let itemManager: ItemManager + let items: Models.DecryptedItemInterface[] + let internalEventBus: InternalEventBusInterface + + const createService = () => { + return new ItemManager(payloadManager, { supportsFileNavigation: false }, internalEventBus) + } + + beforeEach(() => { + setupRandomUuid() + + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + + payloadManager = new PayloadManager(internalEventBus) + + items = [] as jest.Mocked + itemManager = {} as jest.Mocked + itemManager.getItems = jest.fn().mockReturnValue(items) + itemManager.createItem = jest.fn() + itemManager.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked) + itemManager.setItemsToBeDeleted = jest.fn() + itemManager.addObserver = jest.fn() + itemManager.changeItem = jest.fn() + itemManager.changeFeatureRepo = jest.fn() + }) + + const createTag = (title: string) => { + return new Models.SNTag( + new Models.DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.Tag, + content: Models.FillItemContent({ + title: title, + }), + ...PayloadTimestampDefaults(), + }), + ) + } + + const createNote = (title: string) => { + return new Models.SNNote( + new Models.DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.Note, + content: Models.FillItemContent({ + title: title, + }), + ...PayloadTimestampDefaults(), + }), + ) + } + + const createFile = (name: string) => { + return new Models.FileItem( + new Models.DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.File, + content: Models.FillItemContent({ + name: name, + }), + ...PayloadTimestampDefaults(), + }), + ) + } + + describe('item emit', () => { + it('deleted payloads should map to removed items', async () => { + itemManager = createService() + + const payload = new DeletedPayload({ + uuid: String(Math.random()), + content_type: ContentType.Note, + content: undefined, + deleted: true, + dirty: true, + ...PayloadTimestampDefaults(), + }) + + const mockFn = jest.fn() + + itemManager['notifyObservers'] = mockFn + + await payloadManager.emitPayload(payload, Models.PayloadEmitSource.LocalInserted) + + expect(mockFn.mock.calls[0][2]).toHaveLength(1) + }) + + it('decrypted items who become encrypted should be removed from ui', async () => { + itemManager = createService() + + const decrypted = new DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.Note, + content: FillItemContent({ + title: 'foo', + }), + ...PayloadTimestampDefaults(), + }) + + await payloadManager.emitPayload(decrypted, Models.PayloadEmitSource.LocalInserted) + + const encrypted = new EncryptedPayload({ + ...decrypted, + content: '004:...', + enc_item_key: '004:...', + items_key_id: '123', + waitingForKey: true, + errorDecrypting: true, + }) + + const mockFn = jest.fn() + + itemManager['notifyObservers'] = mockFn + + await payloadManager.emitPayload(encrypted, Models.PayloadEmitSource.LocalInserted) + + expect(mockFn.mock.calls[0][2]).toHaveLength(1) + }) + }) + + describe('note display criteria', () => { + it('viewing notes with tag', async () => { + itemManager = createService() + const tag = createTag('parent') + const note = createNote('note') + await itemManager.insertItems([tag, note]) + await itemManager.addTagToNote(note, tag, false) + + itemManager.setPrimaryItemDisplayOptions({ + tags: [tag], + sortBy: 'title', + sortDirection: 'asc', + }) + + const notes = itemManager.getDisplayableNotes() + expect(notes).toHaveLength(1) + }) + }) + + describe('tag relationships', () => { + it('updates parentId of child tag', async () => { + itemManager = createService() + const parent = createTag('parent') + const child = createTag('child') + await itemManager.insertItems([parent, child]) + await itemManager.setTagParent(parent, child) + + const changedChild = itemManager.findItem(child.uuid) as Models.SNTag + expect(changedChild.parentId).toBe(parent.uuid) + }) + + it('forbids a tag to be its own parent', async () => { + itemManager = createService() + const tag = createTag('tag') + await itemManager.insertItems([tag]) + + expect(() => itemManager.setTagParent(tag, tag)).toThrow() + expect(itemManager.getTagParent(tag)).toBeUndefined() + }) + + it('forbids a tag to be its own ancestor', async () => { + itemManager = createService() + const grandParent = createTag('grandParent') + const parent = createTag('parent') + const child = createTag('child') + + await itemManager.insertItems([child, parent, grandParent]) + await itemManager.setTagParent(parent, child) + await itemManager.setTagParent(grandParent, parent) + + expect(() => itemManager.setTagParent(child, grandParent)).toThrow() + expect(itemManager.getTagParent(grandParent)).toBeUndefined() + }) + + it('getTagParent', async () => { + itemManager = createService() + const parent = createTag('parent') + const child = createTag('child') + await itemManager.insertItems([parent, child]) + await itemManager.setTagParent(parent, child) + + expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid) + }) + + it('findTagByTitleAndParent', async () => { + itemManager = createService() + const parent = createTag('name1') + const child = createTag('childName') + const duplicateNameChild = createTag('name1') + + await itemManager.insertItems([parent, child, duplicateNameChild]) + await itemManager.setTagParent(parent, child) + await itemManager.setTagParent(parent, duplicateNameChild) + + const a = itemManager.findTagByTitleAndParent('name1', undefined) + const b = itemManager.findTagByTitleAndParent('name1', parent) + const c = itemManager.findTagByTitleAndParent('name1', child) + + expect(a?.uuid).toEqual(parent.uuid) + expect(b?.uuid).toEqual(duplicateNameChild.uuid) + expect(c?.uuid).toEqual(undefined) + }) + + it('findOrCreateTagByTitle', async () => { + setupRandomUuid() + itemManager = createService() + const parent = createTag('parent') + const child = createTag('child') + await itemManager.insertItems([parent, child]) + await itemManager.setTagParent(parent, child) + + const childA = await itemManager.findOrCreateTagByTitle('child') + const childB = await itemManager.findOrCreateTagByTitle('child', parent) + const childC = await itemManager.findOrCreateTagByTitle('child-bis', parent) + const childD = await itemManager.findOrCreateTagByTitle('child-bis', parent) + + expect(childA.uuid).not.toEqual(child.uuid) + expect(childB.uuid).toEqual(child.uuid) + expect(childD.uuid).toEqual(childC.uuid) + + expect(itemManager.getTagParent(childA)?.uuid).toBe(undefined) + expect(itemManager.getTagParent(childB)?.uuid).toBe(parent.uuid) + expect(itemManager.getTagParent(childC)?.uuid).toBe(parent.uuid) + expect(itemManager.getTagParent(childD)?.uuid).toBe(parent.uuid) + }) + + it('findOrCreateTagParentChain', async () => { + itemManager = createService() + const parent = createTag('parent') + const child = createTag('child') + + await itemManager.insertItems([parent, child]) + await itemManager.setTagParent(parent, child) + + const a = await itemManager.findOrCreateTagParentChain(['parent']) + const b = await itemManager.findOrCreateTagParentChain(['parent', 'child']) + const c = await itemManager.findOrCreateTagParentChain(['parent', 'child2']) + const d = await itemManager.findOrCreateTagParentChain(['parent2', 'child1']) + + expect(a?.uuid).toEqual(parent.uuid) + expect(b?.uuid).toEqual(child.uuid) + + expect(c?.uuid).not.toEqual(parent.uuid) + expect(c?.uuid).not.toEqual(child.uuid) + expect(c?.parentId).toEqual(parent.uuid) + + expect(d?.uuid).not.toEqual(parent.uuid) + expect(d?.uuid).not.toEqual(child.uuid) + expect(d?.parentId).not.toEqual(parent.uuid) + }) + + it('isAncestor', async () => { + itemManager = createService() + const grandParent = createTag('grandParent') + const parent = createTag('parent') + const child = createTag('child') + const another = createTag('another') + + await itemManager.insertItems([child, parent, grandParent, another]) + await itemManager.setTagParent(parent, child) + await itemManager.setTagParent(grandParent, parent) + + expect(itemManager.isTagAncestor(grandParent, parent)).toEqual(true) + expect(itemManager.isTagAncestor(grandParent, child)).toEqual(true) + expect(itemManager.isTagAncestor(parent, child)).toEqual(true) + + expect(itemManager.isTagAncestor(parent, grandParent)).toBeFalsy() + expect(itemManager.isTagAncestor(child, grandParent)).toBeFalsy() + expect(itemManager.isTagAncestor(grandParent, grandParent)).toBeFalsy() + + expect(itemManager.isTagAncestor(another, grandParent)).toBeFalsy() + expect(itemManager.isTagAncestor(child, another)).toBeFalsy() + expect(itemManager.isTagAncestor(grandParent, another)).toBeFalsy() + }) + + it('unsetTagRelationship', async () => { + itemManager = createService() + const parent = createTag('parent') + const child = createTag('child') + await itemManager.insertItems([parent, child]) + await itemManager.setTagParent(parent, child) + expect(itemManager.getTagParent(child)?.uuid).toBe(parent.uuid) + + await itemManager.unsetTagParent(child) + + expect(itemManager.getTagParent(child)).toBeUndefined() + }) + + it('getTagParentChain', async () => { + itemManager = createService() + const greatGrandParent = createTag('greatGrandParent') + const grandParent = createTag('grandParent') + const parent = createTag('parent') + const child = createTag('child') + await itemManager.insertItems([greatGrandParent, grandParent, parent, child]) + await itemManager.setTagParent(parent, child) + await itemManager.setTagParent(grandParent, parent) + await itemManager.setTagParent(greatGrandParent, grandParent) + + const uuidChain = itemManager.getTagParentChain(child).map((tag) => tag.uuid) + + expect(uuidChain).toHaveLength(3) + expect(uuidChain).toEqual([greatGrandParent.uuid, grandParent.uuid, parent.uuid]) + }) + + it('viewing notes for parent tag should not display notes of children', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + await itemManager.insertItems([parentTag, childTag]) + await itemManager.setTagParent(parentTag, childTag) + + const parentNote = createNote('parentNote') + const childNote = createNote('childNote') + await itemManager.insertItems([parentNote, childNote]) + + await itemManager.addTagToNote(parentNote, parentTag, false) + await itemManager.addTagToNote(childNote, childTag, false) + + itemManager.setPrimaryItemDisplayOptions({ + tags: [parentTag], + sortBy: 'title', + sortDirection: 'asc', + }) + + const notes = itemManager.getDisplayableNotes() + expect(notes).toHaveLength(1) + }) + + it('adding a note to a tag hierarchy should add the note to its parent too', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const note = createNote('note') + + await itemManager.insertItems([parentTag, childTag, note]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToNote(note, childTag, true) + + const tags = itemManager.getSortedTagsForNote(note) + + expect(tags).toHaveLength(2) + expect(tags[0].uuid).toEqual(childTag.uuid) + expect(tags[1].uuid).toEqual(parentTag.uuid) + }) + + it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const note = createNote('note') + + await itemManager.insertItems([parentTag, childTag, note]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToNote(note, childTag, false) + + const tags = itemManager.getSortedTagsForNote(note) + + expect(tags).toHaveLength(1) + expect(tags[0].uuid).toEqual(childTag.uuid) + }) + }) + + describe('template items', () => { + it('create template item', async () => { + itemManager = createService() + setupRandomUuid() + + const item = await itemManager.createTemplateItem(ContentType.Note, { + title: 'hello', + references: [], + }) + + expect(!!item).toEqual(true) + /* Template items should never be added to the record */ + expect(itemManager.items).toHaveLength(0) + expect(itemManager.getDisplayableNotes()).toHaveLength(0) + }) + + it('isTemplateItem return the correct value', async () => { + itemManager = createService() + setupRandomUuid() + + const item = await itemManager.createTemplateItem(ContentType.Note, { + title: 'hello', + references: [], + }) + + expect(itemManager.isTemplateItem(item)).toEqual(true) + + await itemManager.insertItem(item) + + expect(itemManager.isTemplateItem(item)).toEqual(false) + }) + + it('isTemplateItem return the correct value for system smart views', () => { + itemManager = createService() + setupRandomUuid() + + const [systemTag1, ...restOfSystemViews] = itemManager + .getSmartViews() + .filter((view) => Object.values(Models.SystemViewId).includes(view.uuid as Models.SystemViewId)) + + const isSystemTemplate = itemManager.isTemplateItem(systemTag1) + expect(isSystemTemplate).toEqual(false) + + const areTemplates = restOfSystemViews.map((tag) => itemManager.isTemplateItem(tag)).every((value) => !!value) + expect(areTemplates).toEqual(false) + }) + }) + + describe('tags', () => { + it('lets me create a regular tag with a clear API', async () => { + itemManager = createService() + setupRandomUuid() + + const tag = await itemManager.createTag('this is my new tag') + + expect(tag).toBeTruthy() + expect(itemManager.isTemplateItem(tag)).toEqual(false) + }) + + it('should search tags correctly', async () => { + itemManager = createService() + setupRandomUuid() + + const foo = await itemManager.createTag('foo[') + const foobar = await itemManager.createTag('foo[bar]') + const bar = await itemManager.createTag('bar[') + const barfoo = await itemManager.createTag('bar[foo]') + const fooDelimiter = await itemManager.createTag('bar.foo') + const barFooDelimiter = await itemManager.createTag('baz.bar.foo') + const fooAttached = await itemManager.createTag('Foo') + const note = createNote('note') + await itemManager.insertItems([foo, foobar, bar, barfoo, fooDelimiter, barFooDelimiter, fooAttached, note]) + await itemManager.addTagToNote(note, fooAttached, false) + + const fooResults = itemManager.searchTags('foo') + expect(fooResults).toContainEqual(foo) + expect(fooResults).toContainEqual(foobar) + expect(fooResults).toContainEqual(barfoo) + expect(fooResults).toContainEqual(fooDelimiter) + expect(fooResults).toContainEqual(barFooDelimiter) + expect(fooResults).not.toContainEqual(bar) + expect(fooResults).not.toContainEqual(fooAttached) + }) + }) + + describe('tags notes index', () => { + it('counts countable notes', async () => { + itemManager = createService() + + const parentTag = createTag('parent') + const childTag = createTag('child') + await itemManager.insertItems([parentTag, childTag]) + await itemManager.setTagParent(parentTag, childTag) + + const parentNote = createNote('parentNote') + const childNote = createNote('childNote') + await itemManager.insertItems([parentNote, childNote]) + + await itemManager.addTagToNote(parentNote, parentTag, false) + await itemManager.addTagToNote(childNote, childTag, false) + + expect(itemManager.countableNotesForTag(parentTag)).toBe(1) + expect(itemManager.countableNotesForTag(childTag)).toBe(1) + expect(itemManager.allCountableNotesCount()).toBe(2) + }) + + it('archiving a note should update count index', async () => { + itemManager = createService() + + const tag1 = createTag('tag 1') + await itemManager.insertItems([tag1]) + + const note1 = createNote('note 1') + const note2 = createNote('note 2') + await itemManager.insertItems([note1, note2]) + + await itemManager.addTagToNote(note1, tag1, false) + await itemManager.addTagToNote(note2, tag1, false) + + expect(itemManager.countableNotesForTag(tag1)).toBe(2) + expect(itemManager.allCountableNotesCount()).toBe(2) + + await itemManager.changeItem(note1, (m) => { + m.archived = true + }) + + expect(itemManager.allCountableNotesCount()).toBe(1) + expect(itemManager.countableNotesForTag(tag1)).toBe(1) + + await itemManager.changeItem(note1, (m) => { + m.archived = false + }) + + expect(itemManager.allCountableNotesCount()).toBe(2) + expect(itemManager.countableNotesForTag(tag1)).toBe(2) + }) + }) + + describe('smart views', () => { + it('lets me create a smart view', async () => { + itemManager = createService() + setupRandomUuid() + + const [view1, view2, view3] = await Promise.all([ + itemManager.createSmartView('Not Pinned', NotPinnedPredicate), + itemManager.createSmartView('Last Day', LastDayPredicate), + itemManager.createSmartView('Long', LongTextPredicate), + ]) + + expect(view1).toBeTruthy() + expect(view2).toBeTruthy() + expect(view3).toBeTruthy() + + expect(view1.content_type).toEqual(ContentType.SmartView) + expect(view2.content_type).toEqual(ContentType.SmartView) + expect(view3.content_type).toEqual(ContentType.SmartView) + }) + + it('lets me use a smart view', async () => { + itemManager = createService() + setupRandomUuid() + + const view = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + + const notes = itemManager.notesMatchingSmartView(view) + + expect(notes).toEqual([]) + }) + + it('lets me test if a title is a smart view', () => { + itemManager = createService() + setupRandomUuid() + + expect(itemManager.isSmartViewTitle(VIEW_NOT_PINNED)).toEqual(true) + expect(itemManager.isSmartViewTitle(VIEW_LAST_DAY)).toEqual(true) + expect(itemManager.isSmartViewTitle(VIEW_LONG)).toEqual(true) + + expect(itemManager.isSmartViewTitle('Helloworld')).toEqual(false) + expect(itemManager.isSmartViewTitle('@^![ some title')).toEqual(false) + }) + + it('lets me create a smart view from the DSL', async () => { + itemManager = createService() + setupRandomUuid() + + const [tag1, tag2, tag3] = await Promise.all([ + itemManager.createSmartViewFromDSL(VIEW_NOT_PINNED), + itemManager.createSmartViewFromDSL(VIEW_LAST_DAY), + itemManager.createSmartViewFromDSL(VIEW_LONG), + ]) + + expect(tag1).toBeTruthy() + expect(tag2).toBeTruthy() + expect(tag3).toBeTruthy() + + expect(tag1.content_type).toEqual(ContentType.SmartView) + expect(tag2.content_type).toEqual(ContentType.SmartView) + expect(tag3.content_type).toEqual(ContentType.SmartView) + }) + + it('will create smart view or tags from the generic method', async () => { + itemManager = createService() + setupRandomUuid() + + const someTag = await itemManager.createTagOrSmartView('some-tag') + const someView = await itemManager.createTagOrSmartView(VIEW_LONG) + + expect(someTag.content_type).toEqual(ContentType.Tag) + expect(someView.content_type).toEqual(ContentType.SmartView) + }) + }) + + it('lets me rename a smart view', async () => { + itemManager = createService() + setupRandomUuid() + + const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + + await itemManager.changeItem(tag, (m) => { + m.title = 'New Title' + }) + + const view = itemManager.findItem(tag.uuid) as Models.SmartView + const views = itemManager.getSmartViews() + + expect(view.title).toEqual('New Title') + expect(views.some((tag: Models.SmartView) => tag.title === 'New Title')).toEqual(true) + }) + + it('lets me find a smart view', async () => { + itemManager = createService() + setupRandomUuid() + + const tag = await itemManager.createSmartView('Not Pinned', NotPinnedPredicate) + + const view = itemManager.findItem(tag.uuid) as Models.SmartView + + expect(view).toBeDefined() + }) + + it('untagged notes smart view', async () => { + itemManager = createService() + setupRandomUuid() + + const view = itemManager.untaggedNotesSmartView + + const tag = createTag('tag') + const untaggedNote = createNote('note') + const taggedNote = createNote('taggedNote') + await itemManager.insertItems([tag, untaggedNote, taggedNote]) + + expect(itemManager.notesMatchingSmartView(view)).toHaveLength(2) + + await itemManager.addTagToNote(taggedNote, tag, false) + + expect(itemManager.notesMatchingSmartView(view)).toHaveLength(1) + + expect(view).toBeDefined() + }) + + describe('files', () => { + it('associates with note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + await itemManager.insertItems([note, file]) + + const resultingFile = await itemManager.associateFileWithNote(file, note) + const references = resultingFile.references + + expect(references).toHaveLength(1) + expect(references[0].uuid).toEqual(note.uuid) + }) + + it('disassociates with note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + await itemManager.insertItems([note, file]) + + const associatedFile = await itemManager.associateFileWithNote(file, note) + const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note) + const references = disassociatedFile.references + + expect(references).toHaveLength(0) + }) + + it('should get files associated with note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + const secondFile = createFile('unrelated-file.xlsx') + await itemManager.insertItems([note, file, secondFile]) + + await itemManager.associateFileWithNote(file, note) + + const filesAssociatedWithNote = itemManager.getFilesForNote(note) + + expect(filesAssociatedWithNote).toHaveLength(1) + expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid) + }) + + it('should correctly rename file to filename that has extension', async () => { + itemManager = createService() + const file = createFile('initialName.ext') + await itemManager.insertItems([file]) + + const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt') + + expect(renamedFile.name).toBe('anotherName.anotherExt') + }) + + it('should correctly rename extensionless file to filename that has extension', async () => { + itemManager = createService() + const file = createFile('initialName') + await itemManager.insertItems([file]) + + const renamedFile = await itemManager.renameFile(file, 'anotherName.anotherExt') + + expect(renamedFile.name).toBe('anotherName.anotherExt') + }) + + it('should correctly rename file to filename that does not have extension', async () => { + itemManager = createService() + const file = createFile('initialName.ext') + await itemManager.insertItems([file]) + + const renamedFile = await itemManager.renameFile(file, 'anotherName') + + expect(renamedFile.name).toBe('anotherName') + }) + }) +}) diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts new file mode 100644 index 000000000..656aa8946 --- /dev/null +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -0,0 +1,1365 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { assert, naturalSort, removeFromArray, UuidGenerator, Uuids } from '@standardnotes/utils' +import { ItemsKeyMutator, SNItemsKey } from '@standardnotes/encryption' +import { PayloadManager } from '../Payloads/PayloadManager' +import { TagsToFoldersMigrationApplicator } from '../../Migrations/Applicators/TagsToFolders' +import { TransactionalMutation } from './TransactionalMutation' +import { UuidString } from '../../Types/UuidString' +import * as Models from '@standardnotes/models' +import * as Services from '@standardnotes/services' +import { ItemsClientInterface } from './ItemsClientInterface' +import { PayloadManagerChangeData } from '../Payloads' +import { DiagnosticInfo } from '@standardnotes/services' +import { ApplicationDisplayOptions } from '@Lib/Application/Options/OptionalOptions' +import { CollectionSort } from '@standardnotes/models' + +type ItemsChangeObserver = { + contentType: ContentType[] + callback: Services.ItemManagerChangeObserverCallback +} + +/** + * The item manager is backed by the payload manager. It listens for change events from the + * global payload manager, and converts any payloads to items, then propagates those items to + * listeners on the item manager. When the item manager makes a change to an item, it will modify + * items using a mutator, then emit those payloads to the payload manager. The payload manager + * will then notify its observers (which is us), we'll convert the payloads to items, + * and then we'll propagate them to our listeners. + */ +export class ItemManager + extends Services.AbstractService + implements Services.ItemManagerInterface, ItemsClientInterface +{ + private unsubChangeObserver: () => void + private observers: ItemsChangeObserver[] = [] + private collection!: Models.ItemCollection + private systemSmartViews: Models.SmartView[] + private tagNotesIndex!: Models.TagNotesIndex + + private navigationDisplayController!: Models.ItemDisplayController + private tagDisplayController!: Models.ItemDisplayController + private itemsKeyDisplayController!: Models.ItemDisplayController + private componentDisplayController!: Models.ItemDisplayController + private themeDisplayController!: Models.ItemDisplayController + private fileDisplayController!: Models.ItemDisplayController + private smartViewDisplayController!: Models.ItemDisplayController + + constructor( + private payloadManager: PayloadManager, + private readonly options: ApplicationDisplayOptions = { supportsFileNavigation: false }, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + this.payloadManager = payloadManager + this.systemSmartViews = this.rebuildSystemSmartViews({}) + this.createCollection() + this.unsubChangeObserver = this.payloadManager.addObserver(ContentType.Any, this.setPayloads.bind(this)) + } + + private rebuildSystemSmartViews(criteria: Models.FilterDisplayOptions): Models.SmartView[] { + this.systemSmartViews = Models.BuildSmartViews(criteria, this.options) + return this.systemSmartViews + } + + private createCollection() { + this.collection = new Models.ItemCollection() + + this.navigationDisplayController = new Models.ItemDisplayController( + this.collection, + [ContentType.Note, ContentType.File], + { + sortBy: 'created_at', + sortDirection: 'dsc', + hiddenContentTypes: !this.options.supportsFileNavigation ? [ContentType.File] : [], + }, + ) + this.tagDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.Tag], { + sortBy: 'title', + sortDirection: 'asc', + }) + this.itemsKeyDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.ItemsKey], { + sortBy: 'created_at', + sortDirection: 'asc', + }) + this.componentDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.Component], { + sortBy: 'created_at', + sortDirection: 'asc', + }) + this.themeDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.Theme], { + sortBy: 'title', + sortDirection: 'asc', + }) + this.smartViewDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.SmartView], { + sortBy: 'title', + sortDirection: 'asc', + }) + this.fileDisplayController = new Models.ItemDisplayController(this.collection, [ContentType.File], { + sortBy: 'title', + sortDirection: 'asc', + }) + + this.tagNotesIndex = new Models.TagNotesIndex(this.collection, this.tagNotesIndex?.observers) + } + + private get allDisplayControllers(): Models.ItemDisplayController[] { + return [ + this.navigationDisplayController, + this.tagDisplayController, + this.itemsKeyDisplayController, + this.componentDisplayController, + this.themeDisplayController, + this.smartViewDisplayController, + this.fileDisplayController, + ] + } + + get invalidItems(): Models.EncryptedItemInterface[] { + return this.collection.invalidElements() + } + + public createItemFromPayload(payload: Models.DecryptedPayloadInterface): Models.DecryptedItemInterface { + return Models.CreateDecryptedItemFromPayload(payload) + } + + public createPayloadFromObject(object: Models.DecryptedTransferPayload): Models.DecryptedPayloadInterface { + return new Models.DecryptedPayload(object) + } + + public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void { + const override: Models.FilterDisplayOptions = {} + + if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) { + if (options.includeArchived == undefined) { + override.includeArchived = false + } + if (options.includeTrashed == undefined) { + override.includeTrashed = false + } + } + if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.ArchivedNotes)) { + if (options.includeTrashed == undefined) { + override.includeTrashed = false + } + } + if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.TrashedNotes)) { + if (options.includeArchived == undefined) { + override.includeArchived = true + } + } + + this.rebuildSystemSmartViews({ ...options, ...override }) + + const mostRecentVersionOfTags = options.tags + ?.map((tag) => { + return this.collection.find(tag.uuid) as Models.SNTag + }) + .filter((tag) => tag != undefined) + + const mostRecentVersionOfViews = options.views + ?.map((view) => { + if (Models.isSystemView(view)) { + return this.systemSmartViews.find((systemView) => systemView.uuid === view.uuid) as Models.SmartView + } + return this.collection.find(view.uuid) as Models.SmartView + }) + .filter((view) => view != undefined) + + const updatedOptions: Models.DisplayOptions = { + ...options, + ...override, + ...{ + tags: mostRecentVersionOfTags, + views: mostRecentVersionOfViews, + }, + } + + if (updatedOptions.sortBy === CollectionSort.Title) { + updatedOptions.sortDirection = updatedOptions.sortDirection === 'asc' ? 'dsc' : 'asc' + } + + this.navigationDisplayController.setDisplayOptions({ + customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection), + ...updatedOptions, + }) + } + + public getDisplayableNotes(): Models.SNNote[] { + assert(this.navigationDisplayController.contentTypes.length === 2) + + const fileContentTypeHidden = !this.options.supportsFileNavigation + if (fileContentTypeHidden) { + return this.navigationDisplayController.items() as Models.SNNote[] + } else { + return this.navigationDisplayController.items().filter(Models.isNote) + } + } + + public getDisplayableFiles(): Models.FileItem[] { + return this.fileDisplayController.items() + } + + public getDisplayableNotesAndFiles(): (Models.SNNote | Models.FileItem)[] { + assert(this.options.supportsFileNavigation) + return this.navigationDisplayController.items() + } + + public getDisplayableTags(): Models.SNTag[] { + return this.tagDisplayController.items() + } + + public getDisplayableItemsKeys(): SNItemsKey[] { + return this.itemsKeyDisplayController.items() + } + + public getDisplayableComponents(): (Models.SNComponent | Models.SNTheme)[] { + return [...this.componentDisplayController.items(), ...this.themeDisplayController.items()] + } + + public override deinit(): void { + this.unsubChangeObserver() + ;(this.options as unknown) = undefined + ;(this.unsubChangeObserver as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.collection as unknown) = undefined + ;(this.tagNotesIndex as unknown) = undefined + ;(this.tagDisplayController as unknown) = undefined + ;(this.navigationDisplayController as unknown) = undefined + ;(this.itemsKeyDisplayController as unknown) = undefined + ;(this.componentDisplayController as unknown) = undefined + ;(this.themeDisplayController as unknown) = undefined + ;(this.fileDisplayController as unknown) = undefined + ;(this.smartViewDisplayController as unknown) = undefined + } + + resetState(): void { + this.createCollection() + } + + findItem(uuid: UuidString): T | undefined { + const itemFromCollection = this.collection.findDecrypted(uuid) + + return itemFromCollection || (this.findSystemSmartView(uuid) as T | undefined) + } + + findAnyItem(uuid: UuidString): Models.ItemInterface | undefined { + const itemFromCollection = this.collection.find(uuid) + + return itemFromCollection || this.findSystemSmartView(uuid) + } + + findAnyItems(uuids: UuidString[]): Models.ItemInterface[] { + return this.collection.findAll(uuids) + } + + private findSystemSmartView(uuid: Uuid): Models.SmartView | undefined { + return this.systemSmartViews.find((view) => view.uuid === uuid) + } + + findSureItem(uuid: UuidString): T { + return this.findItem(uuid) as T + } + + /** + * Returns all items matching given ids + */ + findItems(uuids: UuidString[]): T[] { + return this.collection.findAllDecrypted(uuids) as T[] + } + + /** + * If item is not found, an `undefined` element + * will be inserted into the array. + */ + findItemsIncludingBlanks(uuids: UuidString[]): (T | undefined)[] { + return this.collection.findAllDecryptedWithBlanks(uuids) as (T | undefined)[] + } + + public get items(): Models.DecryptedItemInterface[] { + return this.collection.nondeletedElements().filter(Models.isDecryptedItem) + } + + allTrackedItems(): Models.ItemInterface[] { + return this.collection.all() + } + + public hasTagsNeedingFoldersMigration(): boolean { + return TagsToFoldersMigrationApplicator.isApplicableToCurrentData(this) + } + + public addNoteCountChangeObserver(observer: Models.TagNoteCountChangeObserver): () => void { + return this.tagNotesIndex.addCountChangeObserver(observer) + } + + public allCountableNotesCount(): number { + return this.tagNotesIndex.allCountableNotesCount() + } + + public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number { + if (tag instanceof Models.SmartView) { + if (tag.uuid === Models.SystemViewId.AllNotes) { + return this.tagNotesIndex.allCountableNotesCount() + } + + throw Error('countableNotesForTag is not meant to be used for smart views.') + } + return this.tagNotesIndex.countableNotesForTag(tag) + } + + public getNoteCount(): number { + return this.noteCount + } + + public addObserver( + contentType: ContentType | ContentType[], + callback: Services.ItemManagerChangeObserverCallback, + ): () => void { + if (!Array.isArray(contentType)) { + contentType = [contentType] + } + + const observer: ItemsChangeObserver = { + contentType, + callback, + } + + this.observers.push(observer as ItemsChangeObserver) + + const thislessObservers = this.observers + return () => { + removeFromArray(thislessObservers, observer) + } + } + + /** + * Returns the items that reference the given item, or an empty array if no results. + */ + public itemsReferencingItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + contentType?: ContentType, + ): Models.DecryptedItemInterface[] { + const uuids = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid) + let referencing = this.findItems(uuids) + if (contentType) { + referencing = referencing.filter((ref) => { + return ref?.content_type === contentType + }) + } + return referencing + } + + /** + * Returns all items that an item directly references + */ + public referencesForItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + contentType?: ContentType, + ): Models.DecryptedItemInterface[] { + const item = this.findSureItem(itemToLookupUuidFor.uuid) + const uuids = item.references.map((ref) => ref.uuid) + let references = this.findItems(uuids) + if (contentType) { + references = references.filter((ref) => { + return ref?.content_type === contentType + }) + } + return references + } + + private setPayloads(data: PayloadManagerChangeData) { + const { changed, inserted, discarded, ignored, unerrored, source, sourceKey } = data + + const createItem = (payload: Models.FullyFormedPayloadInterface) => { + return Models.CreateItemFromPayload(payload) + } + + const affectedContentTypes = new Set() + + const changedItems = changed.map((p) => { + affectedContentTypes.add(p.content_type) + return createItem(p) + }) + + const insertedItems = inserted.map((p) => { + affectedContentTypes.add(p.content_type) + return createItem(p) + }) + + const discardedItems: Models.DeletedItemInterface[] = discarded.map((p) => { + affectedContentTypes.add(p.content_type) + return new Models.DeletedItem(p) + }) + + const ignoredItems: Models.EncryptedItemInterface[] = ignored.map((p) => { + affectedContentTypes.add(p.content_type) + return new Models.EncryptedItem(p) + }) + + const unerroredItems = unerrored.map((p) => { + affectedContentTypes.add(p.content_type) + return Models.CreateDecryptedItemFromPayload(p) + }) + + const delta: Models.ItemDelta = { + changed: changedItems, + inserted: insertedItems, + discarded: discardedItems, + ignored: ignoredItems, + unerrored: unerroredItems, + } + + this.collection.onChange(delta) + this.tagNotesIndex.onChange(delta) + + const affectedContentTypesArray = Array.from(affectedContentTypes.values()) + for (const controller of this.allDisplayControllers) { + if (controller.contentTypes.some((ct) => affectedContentTypesArray.includes(ct))) { + controller.onCollectionChange(delta) + } + } + + this.notifyObserversByUiAdjustingDelta(delta, source, sourceKey) + } + + private notifyObserversByUiAdjustingDelta( + delta: Models.ItemDelta, + source: Models.PayloadEmitSource, + sourceKey?: string, + ) { + const changedItems: Models.DecryptedItemInterface[] = [] + const insertedItems: Models.DecryptedItemInterface[] = [] + const changedDeleted: Models.DeletedItemInterface[] = [] + const insertedDeleted: Models.DeletedItemInterface[] = [] + const changedToEncrypted: Models.EncryptedItemInterface[] = [] + + for (const item of delta.changed) { + if (Models.isDeletedItem(item)) { + changedDeleted.push(item) + } else if (Models.isDecryptedItem(item)) { + changedItems.push(item) + } else { + changedToEncrypted.push(item) + } + } + + for (const item of delta.inserted) { + if (Models.isDeletedItem(item)) { + insertedDeleted.push(item) + } else if (Models.isDecryptedItem(item)) { + insertedItems.push(item) + } + } + + const itemsToRemoveFromUI: (Models.DeletedItemInterface | Models.EncryptedItemInterface)[] = [ + ...delta.discarded, + ...changedDeleted, + ...insertedDeleted, + ...changedToEncrypted, + ] + + this.notifyObservers( + changedItems, + insertedItems, + itemsToRemoveFromUI, + delta.ignored, + delta.unerrored, + source, + sourceKey, + ) + } + + private notifyObservers( + changed: Models.DecryptedItemInterface[], + inserted: Models.DecryptedItemInterface[], + removed: (Models.DeletedItemInterface | Models.EncryptedItemInterface)[], + ignored: Models.EncryptedItemInterface[], + unerrored: Models.DecryptedItemInterface[], + source: Models.PayloadEmitSource, + sourceKey?: string, + ) { + const filter = (items: I[], types: ContentType[]) => { + return items.filter((item) => { + return types.includes(ContentType.Any) || types.includes(item.content_type) + }) + } + + const frozenObservers = this.observers.slice() + for (const observer of frozenObservers) { + const filteredChanged = filter(changed, observer.contentType) + const filteredInserted = filter(inserted, observer.contentType) + const filteredDiscarded = filter(removed, observer.contentType) + const filteredIgnored = filter(ignored, observer.contentType) + const filteredUnerrored = filter(unerrored, observer.contentType) + + if ( + filteredChanged.length === 0 && + filteredInserted.length === 0 && + filteredDiscarded.length === 0 && + filteredIgnored.length === 0 && + filteredUnerrored.length === 0 + ) { + continue + } + + observer.callback({ + changed: filteredChanged, + inserted: filteredInserted, + removed: filteredDiscarded, + ignored: filteredIgnored, + unerrored: filteredUnerrored, + source, + sourceKey, + }) + } + } + + /** + * Consumers wanting to modify an item should run it through this block, + * so that data is properly mapped through our function, and latest state + * is properly reconciled. + */ + public async changeItem< + M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator, + I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, + >( + itemToLookupUuidFor: I, + mutate?: (mutator: M) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const results = await this.changeItems( + [itemToLookupUuidFor], + mutate, + mutationType, + emitSource, + payloadSourceKey, + ) + return results[0] + } + + /** + * @param mutate If not supplied, the intention would simply be to mark the item as dirty. + */ + public async changeItems< + M extends Models.DecryptedItemMutator = Models.DecryptedItemMutator, + I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, + >( + itemsToLookupUuidsFor: I[], + mutate?: (mutator: M) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const items = this.findItemsIncludingBlanks(Uuids(itemsToLookupUuidsFor)) + const payloads: Models.DecryptedPayloadInterface[] = [] + + for (const item of items) { + if (!item) { + throw Error('Attempting to change non-existant item') + } + const mutator = Models.CreateDecryptedMutatorForItem(item, mutationType) + if (mutate) { + mutate(mutator as M) + } + const payload = mutator.getResult() + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) + + const results = this.findItems(payloads.map((p) => p.uuid)) as I[] + + return results + } + + /** + * Run unique mutations per each item in the array, then only propagate all changes + * once all mutations have been run. This differs from `changeItems` in that changeItems + * runs the same mutation on all items. + */ + public async runTransactionalMutations( + transactions: TransactionalMutation[], + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise<(Models.DecryptedItemInterface | undefined)[]> { + const payloads: Models.DecryptedPayloadInterface[] = [] + + for (const transaction of transactions) { + const item = this.findItem(transaction.itemUuid) + + if (!item) { + continue + } + + const mutator = Models.CreateDecryptedMutatorForItem( + item, + transaction.mutationType || Models.MutationType.UpdateUserTimestamps, + ) + + transaction.mutate(mutator) + const payload = mutator.getResult() + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, emitSource, payloadSourceKey) + const results = this.findItems(payloads.map((p) => p.uuid)) + return results + } + + public async runTransactionalMutation( + transaction: TransactionalMutation, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const item = this.findSureItem(transaction.itemUuid) + const mutator = Models.CreateDecryptedMutatorForItem( + item, + transaction.mutationType || Models.MutationType.UpdateUserTimestamps, + ) + transaction.mutate(mutator) + const payload = mutator.getResult() + + await this.payloadManager.emitPayloads([payload], emitSource, payloadSourceKey) + const result = this.findItem(payload.uuid) + return result + } + + async changeNote( + itemToLookupUuidFor: Models.SNNote, + mutate: (mutator: Models.NoteMutator) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const note = this.findItem(itemToLookupUuidFor.uuid) + if (!note) { + throw Error('Attempting to change non-existant note') + } + const mutator = new Models.NoteMutator(note, mutationType) + + return this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + } + + async changeTag( + itemToLookupUuidFor: Models.SNTag, + mutate: (mutator: Models.TagMutator) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const tag = this.findItem(itemToLookupUuidFor.uuid) + if (!tag) { + throw Error('Attempting to change non-existant tag') + } + const mutator = new Models.TagMutator(tag, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeComponent( + itemToLookupUuidFor: Models.SNComponent, + mutate: (mutator: Models.ComponentMutator) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const component = this.findItem(itemToLookupUuidFor.uuid) + if (!component) { + throw Error('Attempting to change non-existant component') + } + const mutator = new Models.ComponentMutator(component, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeFeatureRepo( + itemToLookupUuidFor: Models.SNFeatureRepo, + mutate: (mutator: Models.FeatureRepoMutator) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const repo = this.findItem(itemToLookupUuidFor.uuid) + if (!repo) { + throw Error('Attempting to change non-existant repo') + } + const mutator = new Models.FeatureRepoMutator(repo, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeActionsExtension( + itemToLookupUuidFor: Models.SNActionsExtension, + mutate: (mutator: Models.ActionsExtensionMutator) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const extension = this.findItem(itemToLookupUuidFor.uuid) + if (!extension) { + throw Error('Attempting to change non-existant extension') + } + const mutator = new Models.ActionsExtensionMutator(extension, mutationType) + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + return this.findSureItem(itemToLookupUuidFor.uuid) + } + + async changeItemsKey( + itemToLookupUuidFor: Models.ItemsKeyInterface, + mutate: (mutator: Models.ItemsKeyMutatorInterface) => void, + mutationType: Models.MutationType = Models.MutationType.UpdateUserTimestamps, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + const itemsKey = this.findItem(itemToLookupUuidFor.uuid) + + if (!itemsKey) { + throw Error('Attempting to change non-existant itemsKey') + } + + const mutator = new ItemsKeyMutator(itemsKey, mutationType) + + await this.applyTransform(mutator, mutate, emitSource, payloadSourceKey) + + return this.findSureItem(itemToLookupUuidFor.uuid) + } + + private async applyTransform( + mutator: T, + mutate: (mutator: T) => void, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + mutate(mutator) + const payload = mutator.getResult() + return this.payloadManager.emitPayload(payload, emitSource, payloadSourceKey) + } + + /** + * Sets the item as needing sync. The item is then run through the mapping function, + * and propagated to mapping observers. + * @param isUserModified - Whether to update the item's "user modified date" + */ + public async setItemDirty(itemToLookupUuidFor: Models.DecryptedItemInterface, isUserModified = false) { + const result = await this.setItemsDirty([itemToLookupUuidFor], isUserModified) + return result[0] + } + + public async setItemsDirty( + itemsToLookupUuidsFor: Models.DecryptedItemInterface[], + isUserModified = false, + ): Promise { + return this.changeItems( + itemsToLookupUuidsFor, + undefined, + isUserModified ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + ) + } + + /** + * Returns an array of items that need to be synced. + */ + public getDirtyItems(): (Models.DecryptedItemInterface | Models.DeletedItemInterface)[] { + return this.collection.dirtyElements().filter(Models.isDecryptedOrDeletedItem) + } + + /** + * Duplicates an item and maps it, thus propagating the item to observers. + * @param isConflict - Whether to mark the duplicate as a conflict of the original. + */ + public async duplicateItem( + itemToLookupUuidFor: T, + isConflict = false, + additionalContent?: Partial, + ) { + const item = this.findSureItem(itemToLookupUuidFor.uuid) + const payload = item.payload.copy() + const resultingPayloads = Models.PayloadsByDuplicating({ + payload, + baseCollection: this.payloadManager.getMasterCollection(), + isConflict, + additionalContent, + }) + + await this.payloadManager.emitPayloads(resultingPayloads, Models.PayloadEmitSource.LocalChanged) + const duplicate = this.findSureItem(resultingPayloads[0].uuid) + return duplicate + } + + public async createItem( + contentType: ContentType, + content: C, + needsSync = false, + ): Promise { + const payload = new Models.DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: Models.FillItemContent(content), + dirty: needsSync, + ...Models.PayloadTimestampDefaults(), + }) + + await this.payloadManager.emitPayload(payload, Models.PayloadEmitSource.LocalInserted) + + return this.findSureItem(payload.uuid) + } + + public createTemplateItem< + C extends Models.ItemContent = Models.ItemContent, + I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, + >(contentType: ContentType, content?: C): I { + const payload = new Models.DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: Models.FillItemContent(content || {}), + ...Models.PayloadTimestampDefaults(), + }) + const item = Models.CreateDecryptedItemFromPayload(payload) + return item + } + + /** + * @param item item to be checked + * @returns Whether the item is a template (unmanaged) + */ + public isTemplateItem(item: Models.DecryptedItemInterface): boolean { + return !this.findItem(item.uuid) + } + + public async insertItem(item: Models.DecryptedItemInterface): Promise { + return this.emitItemFromPayload(item.payload, Models.PayloadEmitSource.LocalChanged) + } + + public async insertItems( + items: Models.DecryptedItemInterface[], + emitSource: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalInserted, + ): Promise { + return this.emitItemsFromPayloads( + items.map((item) => item.payload), + emitSource, + ) + } + + public async emitItemFromPayload( + payload: Models.DecryptedPayloadInterface, + emitSource: Models.PayloadEmitSource, + ): Promise { + await this.payloadManager.emitPayload(payload, emitSource) + + return this.findSureItem(payload.uuid) + } + + public async emitItemsFromPayloads( + payloads: Models.DecryptedPayloadInterface[], + emitSource: Models.PayloadEmitSource, + ): Promise { + await this.payloadManager.emitPayloads(payloads, emitSource) + + const uuids = Uuids(payloads) + + return this.findItems(uuids) + } + + public async setItemToBeDeleted( + itemToLookupUuidFor: Models.DecryptedItemInterface | Models.EncryptedItemInterface, + source: Models.PayloadEmitSource = Models.PayloadEmitSource.LocalChanged, + ): Promise { + const referencingIdsCapturedBeforeChanges = this.collection.uuidsThatReferenceUuid(itemToLookupUuidFor.uuid) + + const item = this.findAnyItem(itemToLookupUuidFor.uuid) + + if (!item) { + return + } + + const mutator = new Models.DeleteItemMutator(item, Models.MutationType.UpdateUserTimestamps) + + const deletedPayload = mutator.getDeletedResult() + + await this.payloadManager.emitPayload(deletedPayload, source) + + for (const referencingId of referencingIdsCapturedBeforeChanges) { + const referencingItem = this.findItem(referencingId) + + if (referencingItem) { + await this.changeItem(referencingItem, (mutator) => { + mutator.removeItemAsRelationship(item) + }) + } + } + } + + public async setItemsToBeDeleted( + itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[], + ): Promise { + await Promise.all(itemsToLookupUuidsFor.map((item) => this.setItemToBeDeleted(item))) + } + + public getItems(contentType: ContentType | ContentType[]): T[] { + return this.collection.allDecrypted(contentType) + } + + getAnyItems(contentType: ContentType | ContentType[]): Models.ItemInterface[] { + return this.collection.all(contentType) + } + + public itemsMatchingPredicate( + contentType: ContentType, + predicate: Models.PredicateInterface, + ): T[] { + return this.itemsMatchingPredicates(contentType, [predicate]) + } + + public itemsMatchingPredicates( + contentType: ContentType, + predicates: Models.PredicateInterface[], + ): T[] { + const subItems = this.getItems(contentType) + return this.subItemsMatchingPredicates(subItems, predicates) + } + + public subItemsMatchingPredicates( + items: T[], + predicates: Models.PredicateInterface[], + ): T[] { + const results = items.filter((item) => { + for (const predicate of predicates) { + if (!item.satisfiesPredicate(predicate)) { + return false + } + } + return true + }) + + return results + } + + public getRootTags(): Models.SNTag[] { + return this.getDisplayableTags().filter((tag) => tag.parentId === undefined) + } + + public findTagByTitle(title: string): Models.SNTag | undefined { + const lowerCaseTitle = title.toLowerCase() + return this.getDisplayableTags().find((tag) => tag.title?.toLowerCase() === lowerCaseTitle) + } + + public findTagByTitleAndParent(title: string, parentItemToLookupUuidFor?: Models.SNTag): Models.SNTag | undefined { + const lowerCaseTitle = title.toLowerCase() + + const tags = parentItemToLookupUuidFor ? this.getTagChildren(parentItemToLookupUuidFor) : this.getRootTags() + + return tags.find((tag) => tag.title?.toLowerCase() === lowerCaseTitle) + } + + /** + * Finds tags with title or component starting with a search query and (optionally) not associated with a note + * @param searchQuery - The query string to match + * @param note - The note whose tags should be omitted from results + * @returns Array containing tags matching search query and not associated with note + */ + public searchTags(searchQuery: string, note?: Models.SNNote): Models.SNTag[] { + return naturalSort( + this.getDisplayableTags().filter((tag) => { + const expandedTitle = this.getTagLongTitle(tag) + const matchesQuery = expandedTitle.toLowerCase().includes(searchQuery.toLowerCase()) + const tagInNote = note ? this.itemsReferencingItem(note).some((item) => item?.uuid === tag.uuid) : false + return matchesQuery && !tagInNote + }), + 'title', + ) + } + + getTagParent(itemToLookupUuidFor: Models.SNTag): Models.SNTag | undefined { + const tag = this.findItem(itemToLookupUuidFor.uuid) + if (!tag) { + return undefined + } + const parentId = tag.parentId + if (parentId) { + return this.findItem(parentId) as Models.SNTag + } + return undefined + } + + public getTagPrefixTitle(tag: Models.SNTag): string | undefined { + const hierarchy = this.getTagParentChain(tag) + + if (hierarchy.length === 0) { + return undefined + } + + const prefixTitle = hierarchy.map((tag) => tag.title).join('/') + return `${prefixTitle}/` + } + + public getTagLongTitle(tag: Models.SNTag): string { + const hierarchy = this.getTagParentChain(tag) + const tags = [...hierarchy, tag] + const longTitle = tags.map((tag) => tag.title).join('/') + return longTitle + } + + getTagParentChain(itemToLookupUuidFor: Models.SNTag): Models.SNTag[] { + const tag = this.findItem(itemToLookupUuidFor.uuid) + if (!tag) { + return [] + } + + let parentId = tag.parentId + const chain: Models.SNTag[] = [] + + while (parentId) { + const parent = this.findItem(parentId) + if (!parent) { + return chain + } + chain.unshift(parent) + parentId = parent.parentId + } + + return chain + } + + public async findOrCreateTagParentChain(titlesHierarchy: string[]): Promise { + let current: Models.SNTag | undefined = undefined + + for (const title of titlesHierarchy) { + current = await this.findOrCreateTagByTitle(title, current) + } + + if (!current) { + throw new Error('Invalid tag hierarchy') + } + + return current + } + + public getTagChildren(itemToLookupUuidFor: Models.SNTag): Models.SNTag[] { + const tag = this.findItem(itemToLookupUuidFor.uuid) + if (!tag) { + return [] + } + + const tags = this.collection.elementsReferencingElement(tag, ContentType.Tag) as Models.SNTag[] + + return tags.filter((tag) => tag.parentId === itemToLookupUuidFor.uuid) + } + + public isTagAncestor(tagToLookUpUuidFor: Models.SNTag, childToLookUpUuidFor: Models.SNTag): boolean { + const tag = this.findItem(childToLookUpUuidFor.uuid) + if (!tag) { + return false + } + + let parentId = tag.parentId + + while (parentId) { + if (parentId === tagToLookUpUuidFor.uuid) { + return true + } + + const parent = this.findItem(parentId) + if (!parent) { + return false + } + + parentId = parent.parentId + } + + return false + } + + public isValidTagParent(parentTagToLookUpUuidFor: Models.SNTag, childToLookUpUuidFor: Models.SNTag): boolean { + if (parentTagToLookUpUuidFor.uuid === childToLookUpUuidFor.uuid) { + return false + } + + if (this.isTagAncestor(childToLookUpUuidFor, parentTagToLookUpUuidFor)) { + return false + } + + return true + } + + /** + * @returns The changed child tag + */ + public setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise { + if (parentTag.uuid === childTag.uuid) { + throw new Error('Can not set a tag parent of itself') + } + + if (this.isTagAncestor(childTag, parentTag)) { + throw new Error('Can not set a tag ancestor of itself') + } + + return this.changeTag(childTag, (m) => { + m.makeChildOf(parentTag) + }) + } + + /** + * @returns The changed child tag + */ + public unsetTagParent(childTag: Models.SNTag): Promise { + const parentTag = this.getTagParent(childTag) + + if (!parentTag) { + return Promise.resolve(childTag) + } + + return this.changeTag(childTag, (m) => { + m.unsetParent() + }) + } + + public async associateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise { + return this.changeItem(file, (mutator) => { + mutator.addNote(note) + }) + } + + public async disassociateFileWithNote(file: Models.FileItem, note: Models.SNNote): Promise { + return this.changeItem(file, (mutator) => { + mutator.removeNote(note) + }) + } + + public async addTagToNote(note: Models.SNNote, tag: Models.SNTag, addHierarchy: boolean): Promise { + let tagsToAdd = [tag] + + if (addHierarchy) { + const parentChainTags = this.getTagParentChain(tag) + tagsToAdd = [...parentChainTags, tag] + } + + return Promise.all( + tagsToAdd.map((tagToAdd) => { + return this.changeTag(tagToAdd, (mutator) => { + mutator.addNote(note) + }) as Promise + }), + ) + } + + /** + * Get tags for a note sorted in natural order + * @param note - The note whose tags will be returned + * @returns Array containing tags associated with a note + */ + public getSortedTagsForNote(note: Models.SNNote): Models.SNTag[] { + return naturalSort( + this.itemsReferencingItem(note).filter((ref) => { + return ref?.content_type === ContentType.Tag + }) as Models.SNTag[], + 'title', + ) + } + + public async createTag(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise { + const newTag = await this.createItem( + ContentType.Tag, + Models.FillItemContent({ title }), + true, + ) + + if (parentItemToLookupUuidFor) { + const parentTag = this.findItem(parentItemToLookupUuidFor.uuid) + if (!parentTag) { + throw new Error('Invalid parent tag') + } + return this.changeTag(newTag, (m) => { + m.makeChildOf(parentTag) + }) + } + + return newTag + } + + public async createSmartView( + title: string, + predicate: Models.PredicateInterface, + ): Promise { + return this.createItem( + ContentType.SmartView, + Models.FillItemContent({ + title, + predicate: predicate.toJson(), + } as Models.SmartViewContent), + true, + ) as Promise + } + + public async createSmartViewFromDSL(dsl: string): Promise { + let components = null + try { + components = JSON.parse(dsl.substring(1, dsl.length)) + } catch (e) { + throw Error('Invalid smart view syntax') + } + + const title = components[0] + const predicate = Models.predicateFromDSLString(dsl) + return this.createSmartView(title, predicate) + } + + public async createTagOrSmartView(title: string): Promise { + if (this.isSmartViewTitle(title)) { + return this.createSmartViewFromDSL(title) + } else { + return this.createTag(title) + } + } + + public isSmartViewTitle(title: string): boolean { + return title.startsWith(Models.SMART_TAG_DSL_PREFIX) + } + + /** + * Finds or creates a tag with a given title + */ + public async findOrCreateTagByTitle(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise { + const tag = this.findTagByTitleAndParent(title, parentItemToLookupUuidFor) + return tag || this.createTag(title, parentItemToLookupUuidFor) + } + + public notesMatchingSmartView(view: Models.SmartView): Models.SNNote[] { + const criteria: Models.FilterDisplayOptions = { + views: [view], + } + + return Models.itemsMatchingOptions( + criteria, + this.collection.allDecrypted(ContentType.Note), + this.collection, + ) as Models.SNNote[] + } + + public get allNotesSmartView(): Models.SmartView { + return this.systemSmartViews.find((tag) => tag.uuid === Models.SystemViewId.AllNotes) as Models.SmartView + } + + public get archivedSmartView(): Models.SmartView { + return this.systemSmartViews.find((tag) => tag.uuid === Models.SystemViewId.ArchivedNotes) as Models.SmartView + } + + public get trashSmartView(): Models.SmartView { + return this.systemSmartViews.find((tag) => tag.uuid === Models.SystemViewId.TrashedNotes) as Models.SmartView + } + + public get untaggedNotesSmartView(): Models.SmartView { + return this.systemSmartViews.find((tag) => tag.uuid === Models.SystemViewId.UntaggedNotes) as Models.SmartView + } + + public get trashedItems(): Models.SNNote[] { + return this.notesMatchingSmartView(this.trashSmartView) + } + + /** + * Permanently deletes any items currently in the trash. Consumer must manually call sync. + */ + public async emptyTrash(): Promise { + const notes = this.trashedItems + await this.setItemsToBeDeleted(notes) + } + + /** + * Returns all smart views, sorted by title. + */ + public getSmartViews(): Models.SmartView[] { + const userTags = this.smartViewDisplayController.items() + return this.systemSmartViews.concat(userTags) + } + + /** + * The number of notes currently managed + */ + public get noteCount(): number { + return this.collection.all(ContentType.Note).length + } + + /** + * Immediately removes all items from mapping state and notifies observers + * Used primarily when signing into an account and wanting to discard any current + * local data. + */ + public async removeAllItemsFromMemory(): Promise { + const uuids = Uuids(this.items) + const results: Models.DeletedPayloadInterface[] = [] + + for (const uuid of uuids) { + const mutator = new Models.DeleteItemMutator( + this.findSureItem(uuid), + /** We don't want to set as dirty, since we want to dispose of immediately. */ + Models.MutationType.NonDirtying, + ) + results.push(mutator.getDeletedResult()) + } + + await this.payloadManager.emitPayloads(results, Models.PayloadEmitSource.LocalChanged) + + this.resetState() + this.payloadManager.resetState() + } + + public removeItemLocally(item: Models.DecryptedItemInterface | Models.DeletedItemInterface): void { + this.collection.discard([item]) + this.payloadManager.removePayloadLocally(item.payload) + + const delta = Models.CreateItemDelta({ discarded: [item] as Models.DeletedItemInterface[] }) + for (const controller of this.allDisplayControllers) { + if (controller.contentTypes.some((ct) => ct === item.content_type)) { + controller.onCollectionChange(delta) + } + } + } + + public getFilesForNote(note: Models.SNNote): Models.FileItem[] { + return ( + this.itemsReferencingItem(note).filter((ref) => ref.content_type === ContentType.File) as Models.FileItem[] + ).sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)) + } + + public renameFile(file: Models.FileItem, name: string): Promise { + return this.changeItem(file, (mutator) => { + mutator.name = name + }) + } + + public async setLastSyncBeganForItems( + itemsToLookupUuidsFor: (Models.DecryptedItemInterface | Models.DeletedItemInterface)[], + date: Date, + globalDirtyIndex: number, + ): Promise<(Models.DecryptedItemInterface | Models.DeletedItemInterface)[]> { + const uuids = Uuids(itemsToLookupUuidsFor) + + const items = this.collection.findAll(uuids).filter(Models.isDecryptedOrDeletedItem) + + const payloads: (Models.DecryptedPayloadInterface | Models.DeletedPayloadInterface)[] = [] + + for (const item of items) { + const mutator = new Models.ItemMutator( + item, + Models.MutationType.NonDirtying, + ) + + mutator.setBeginSync(date, globalDirtyIndex) + + const payload = mutator.getResult() + + payloads.push(payload) + } + + await this.payloadManager.emitPayloads(payloads, Models.PayloadEmitSource.PreSyncSave) + + return this.findAnyItems(uuids) as (Models.DecryptedItemInterface | Models.DeletedItemInterface)[] + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + items: { + allIds: Uuids(this.collection.all()), + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Items/ItemsClientInterface.ts b/packages/snjs/lib/Services/Items/ItemsClientInterface.ts new file mode 100644 index 000000000..b6324f593 --- /dev/null +++ b/packages/snjs/lib/Services/Items/ItemsClientInterface.ts @@ -0,0 +1,141 @@ +import { SNItemsKey } from '@standardnotes/encryption' +import { ContentType } from '@standardnotes/common' +import { + SNNote, + FileItem, + SNTag, + SmartView, + TagNoteCountChangeObserver, + DecryptedPayloadInterface, + EncryptedItemInterface, + DecryptedTransferPayload, + PredicateInterface, + DecryptedItemInterface, + SNComponent, + SNTheme, + DisplayOptions, +} from '@standardnotes/models' +import { UuidString } from '@Lib/Types' + +export interface ItemsClientInterface { + get invalidItems(): EncryptedItemInterface[] + + associateFileWithNote(file: FileItem, note: SNNote): Promise + + disassociateFileWithNote(file: FileItem, note: SNNote): Promise + + getFilesForNote(note: SNNote): FileItem[] + + renameFile(file: FileItem, name: string): Promise + + addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise + + /** Creates an unmanaged, un-inserted item from a payload. */ + createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface + + createPayloadFromObject(object: DecryptedTransferPayload): DecryptedPayloadInterface + + get trashedItems(): SNNote[] + + setPrimaryItemDisplayOptions(options: DisplayOptions): void + + getDisplayableNotes(): SNNote[] + + getDisplayableTags(): SNTag[] + + getDisplayableItemsKeys(): SNItemsKey[] + + getDisplayableFiles(): FileItem[] + + getDisplayableNotesAndFiles(): (SNNote | FileItem)[] + + getDisplayableComponents(): (SNComponent | SNTheme)[] + + getItems(contentType: ContentType | ContentType[]): T[] + + notesMatchingSmartView(view: SmartView): SNNote[] + + addNoteCountChangeObserver(observer: TagNoteCountChangeObserver): () => void + + allCountableNotesCount(): number + + countableNotesForTag(tag: SNTag | SmartView): number + + findTagByTitle(title: string): SNTag | undefined + + getTagPrefixTitle(tag: SNTag): string | undefined + + getTagLongTitle(tag: SNTag): string + + hasTagsNeedingFoldersMigration(): boolean + + referencesForItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[] + + itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[] + + /** + * Finds tags with title or component starting with a search query and (optionally) not associated with a note + * @param searchQuery - The query string to match + * @param note - The note whose tags should be omitted from results + * @returns Array containing tags matching search query and not associated with note + */ + searchTags(searchQuery: string, note?: SNNote): SNTag[] + + isValidTagParent(parentTagToLookUpUuidFor: SNTag, childToLookUpUuidFor: SNTag): boolean + + /** + * Returns the parent for a tag + */ + getTagParent(itemToLookupUuidFor: SNTag): SNTag | undefined + + /** + * Returns the hierarchy of parents for a tag + * @returns Array containing all parent tags + */ + getTagParentChain(itemToLookupUuidFor: SNTag): SNTag[] + + /** + * Returns all descendants for a tag + * @returns Array containing all descendant tags + */ + getTagChildren(itemToLookupUuidFor: SNTag): SNTag[] + + /** + * Get tags for a note sorted in natural order + * @param note - The note whose tags will be returned + * @returns Array containing tags associated with a note + */ + getSortedTagsForNote(note: SNNote): SNTag[] + + isSmartViewTitle(title: string): boolean + + getSmartViews(): SmartView[] + + getNoteCount(): number + + /** + * Finds an item by UUID. + */ + findItem(uuid: UuidString): T | undefined + + /** + * Finds an item by predicate. + */ + findItems(uuids: UuidString[]): T[] + + findSureItem(uuid: UuidString): T + + /** + * Finds an item by predicate. + */ + itemsMatchingPredicate( + contentType: ContentType, + predicate: PredicateInterface, + ): T[] + + /** + * @param item item to be checked + * @returns Whether the item is a template (unmanaged) + */ + isTemplateItem(item: DecryptedItemInterface): boolean +} diff --git a/packages/snjs/lib/Services/Items/TransactionalMutation.ts b/packages/snjs/lib/Services/Items/TransactionalMutation.ts new file mode 100644 index 000000000..a4e2eb2dc --- /dev/null +++ b/packages/snjs/lib/Services/Items/TransactionalMutation.ts @@ -0,0 +1,8 @@ +import * as Models from '@standardnotes/models' +import { UuidString } from '../../Types/UuidString' + +export type TransactionalMutation = { + itemUuid: UuidString + mutate: (mutator: Models.ItemMutator) => void + mutationType?: Models.MutationType +} diff --git a/packages/snjs/lib/Services/Items/index.ts b/packages/snjs/lib/Services/Items/index.ts new file mode 100644 index 000000000..c708e0351 --- /dev/null +++ b/packages/snjs/lib/Services/Items/index.ts @@ -0,0 +1,3 @@ +export * from './ItemsClientInterface' +export * from './ItemManager' +export * from './TransactionalMutation' diff --git a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryOperation.ts b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryOperation.ts new file mode 100644 index 000000000..ffc7b18ad --- /dev/null +++ b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryOperation.ts @@ -0,0 +1,65 @@ +import { ContentType } from '@standardnotes/common' +import { ItemsKeyInterface } from '@standardnotes/models' +import { dateSorted } from '@standardnotes/utils' +import { SNRootKeyParams, DecryptItemsKeyByPromptingUser, EncryptionProvider } from '@standardnotes/encryption' +import { DecryptionQueueItem, KeyRecoveryOperationResult } from './Types' +import { serverKeyParamsAreSafe } from './Utils' +import { ChallengeServiceInterface } from '@standardnotes/services' +import { ItemManager } from '../Items' + +export class KeyRecoveryOperation { + constructor( + private queueItem: DecryptionQueueItem, + private itemManager: ItemManager, + private protocolService: EncryptionProvider, + private challengeService: ChallengeServiceInterface, + private clientParams: SNRootKeyParams | undefined, + private serverParams: SNRootKeyParams | undefined, + ) {} + + public async run(): Promise { + let replaceLocalRootKeyWithResult = false + + const queueItemKeyParamsAreBetterOrEqualToClients = + this.serverParams && + this.clientParams && + !this.clientParams.compare(this.serverParams) && + this.queueItem.keyParams.compare(this.serverParams) && + serverKeyParamsAreSafe(this.serverParams, this.clientParams) + + if (queueItemKeyParamsAreBetterOrEqualToClients) { + const latestDecryptedItemsKey = dateSorted( + this.itemManager.getItems(ContentType.ItemsKey), + 'created_at', + false, + )[0] + + if (!latestDecryptedItemsKey) { + replaceLocalRootKeyWithResult = true + } else { + replaceLocalRootKeyWithResult = this.queueItem.encryptedKey.created_at > latestDecryptedItemsKey.created_at + } + } + + const decryptionResult = await DecryptItemsKeyByPromptingUser( + this.queueItem.encryptedKey, + this.protocolService, + this.challengeService, + this.queueItem.keyParams, + ) + + if (decryptionResult === 'aborted') { + return { aborted: true } + } + + if (decryptionResult === 'failed') { + return { aborted: false } + } + + return { + rootKey: decryptionResult.rootKey, + replaceLocalRootKeyWithResult, + decryptedItemsKey: decryptionResult.decryptedKey, + } + } +} diff --git a/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts new file mode 100644 index 000000000..3a3a5a385 --- /dev/null +++ b/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts @@ -0,0 +1,535 @@ +import { KeyRecoveryOperation } from './KeyRecoveryOperation' +import { + SNRootKeyParams, + EncryptionService, + SNRootKey, + KeyParamsFromApiResponse, + KeyRecoveryStrings, +} from '@standardnotes/encryption' +import { UserService } from '../User/UserService' +import { + isErrorDecryptingPayload, + EncryptedPayloadInterface, + EncryptedPayload, + isDecryptedPayload, + DecryptedPayloadInterface, + PayloadEmitSource, + EncryptedItemInterface, + getIncrementedDirtyIndex, +} from '@standardnotes/models' +import { SNSyncService } from '../Sync/SyncService' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { PayloadManager } from '../Payloads/PayloadManager' +import { Challenge, ChallengeService } from '../Challenge' +import { SNApiService } from '@Lib/Services/Api/ApiService' +import { ContentType, Uuid } from '@standardnotes/common' +import { ItemManager } from '../Items/ItemManager' +import { removeFromArray, Uuids } from '@standardnotes/utils' +import { ClientDisplayableError, KeyParamsResponse } from '@standardnotes/responses' +import { + AlertService, + AbstractService, + InternalEventBusInterface, + StorageValueModes, + ApplicationStage, + StorageKey, + DiagnosticInfo, + ChallengeValidation, + ChallengeReason, + ChallengePrompt, +} from '@standardnotes/services' +import { + UndecryptableItemsStorage, + DecryptionQueueItem, + KeyRecoveryEvent, + isSuccessResult, + KeyRecoveryOperationResult, +} from './Types' +import { serverKeyParamsAreSafe } from './Utils' + +/** + * The key recovery service listens to items key changes to detect any that cannot be decrypted. + * If it detects an items key that is not properly decrypted, it will present a key recovery + * wizard (using existing UI like Challenges and AlertService) that will attempt to recover + * the root key for those keys. + * + * When we encounter an items key we cannot decrypt, this is a sign that the user's password may + * have recently changed (even though their session is still valid). If the user has been + * previously signed in, we take this opportunity to reach out to the server to get the + * user's current key_params. We ensure these key params' version is equal to or greater than our own. + + * - If this key's key params are equal to the retrieved parameters, + and this keys created date is greater than any existing valid items key, + or if we do not have any items keys: + 1. Use the decryption of this key as a source of validation + 2. If valid, replace our local root key with this new root key and emit the decrypted items key + * - Else, if the key params are not equal, + or its created date is less than an existing valid items key + 1. Attempt to decrypt this key using its attached key paramas + 2. If valid, emit decrypted items key. DO NOT replace local root key. + * - If by the end we did not find an items key with matching key params to the retrieved + key params, AND the retrieved key params are newer than what we have locally, we must + issue a sign in request to the server. + + * If the user is not signed in and we detect an undecryptable items key, we present a detached + * recovery wizard that doesn't affect our local root key. + * + * When an items key is emitted, protocol service will automatically try to decrypt any + * related items that are in an errored state. + * + * In the item observer, `ignored` items represent items who have encrypted overwrite + * protection enabled (only items keys). This means that if the incoming payload is errored, + * but our current copy is not, we will ignore the incoming value until we can properly + * decrypt it. + */ +export class SNKeyRecoveryService extends AbstractService { + private removeItemObserver: () => void + private decryptionQueue: DecryptionQueueItem[] = [] + private isProcessingQueue = false + + constructor( + private itemManager: ItemManager, + private payloadManager: PayloadManager, + private apiService: SNApiService, + private protocolService: EncryptionService, + private challengeService: ChallengeService, + private alertService: AlertService, + private storageService: DiskStorageService, + private syncService: SNSyncService, + private userService: UserService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + + this.removeItemObserver = this.payloadManager.addObserver( + [ContentType.ItemsKey], + ({ changed, inserted, ignored, source }) => { + if (source === PayloadEmitSource.LocalChanged) { + return + } + + const changedOrInserted = changed.concat(inserted).filter(isErrorDecryptingPayload) + + if (changedOrInserted.length > 0) { + void this.handleUndecryptableItemsKeys(changedOrInserted) + } + + if (ignored.length > 0) { + void this.handleIgnoredItemsKeys(ignored) + } + }, + ) + } + + public override deinit(): void { + ;(this.itemManager as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.apiService as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.userService as unknown) = undefined + + this.removeItemObserver() + ;(this.removeItemObserver as unknown) = undefined + + super.deinit() + } + + // eslint-disable-next-line @typescript-eslint/require-await + override async handleApplicationStage(stage: ApplicationStage): Promise { + void super.handleApplicationStage(stage) + if (stage === ApplicationStage.LoadedDatabase_12) { + void this.processPersistedUndecryptables() + } + } + + /** + * Ignored items keys are items keys which arrived from a remote source, which we were + * not able to decrypt, and for which we already had an existing items key that was + * properly decrypted. Since items keys key contents are immutable, if we already have a + * successfully decrypted version, yet we can't decrypt the new version, we should + * temporarily ignore the new version until we can properly decrypt it (through the recovery flow), + * and not overwrite the local copy. + * + * Ignored items are persisted to disk in isolated storage so that they may be decrypted + * whenever. When they are finally decryptable, we will emit them and update our database + * with the new decrypted value. + * + * When the app first launches, we will query the isolated storage to see if there are any + * keys we need to decrypt. + */ + private async handleIgnoredItemsKeys(keys: EncryptedPayloadInterface[], persistIncoming = true) { + /** + * Persist the keys locally in isolated storage, so that if we don't properly decrypt + * them in this app session, the user has a chance to later. If there already exists + * the same items key in this storage, replace it with this latest incoming value. + */ + if (persistIncoming) { + this.saveToUndecryptables(keys) + } + + this.addKeysToQueue(keys) + + await this.beginKeyRecovery() + } + + private async handleUndecryptableItemsKeys(keys: EncryptedPayloadInterface[]) { + this.addKeysToQueue(keys) + + await this.beginKeyRecovery() + } + + public presentKeyRecoveryWizard(): void { + const invalidKeys = this.itemManager.invalidItems + .filter((i) => i.content_type === ContentType.ItemsKey) + .map((i) => i.payload) + + void this.handleIgnoredItemsKeys(invalidKeys, false) + } + + public canAttemptDecryptionOfItem(item: EncryptedItemInterface): ClientDisplayableError | true { + const keyId = item.payload.items_key_id + + if (!keyId) { + return new ClientDisplayableError('This item cannot be recovered.') + } + + const key = this.payloadManager.findOne(keyId) + + if (!key) { + return new ClientDisplayableError( + `Unable to find key ${keyId} for this item. You may try signing out and back in; if that doesn't help, check your backup files for a key with this ID and import it.`, + ) + } + + return true + } + + public async processPersistedUndecryptables() { + const record = this.getUndecryptables() + + const rawPayloads = Object.values(record) + + if (rawPayloads.length === 0) { + return + } + + const keys = rawPayloads.map((raw) => new EncryptedPayload(raw)) + + return this.handleIgnoredItemsKeys(keys, false) + } + + private getUndecryptables(): UndecryptableItemsStorage { + return this.storageService.getValue( + StorageKey.KeyRecoveryUndecryptableItems, + StorageValueModes.Default, + {}, + ) + } + + private persistUndecryptables(record: UndecryptableItemsStorage) { + this.storageService.setValue(StorageKey.KeyRecoveryUndecryptableItems, record) + } + + private saveToUndecryptables(keys: EncryptedPayloadInterface[]) { + const record = this.getUndecryptables() + + for (const key of keys) { + record[key.uuid] = key.ejected() + } + + this.persistUndecryptables(record) + } + + private removeFromUndecryptables(keyIds: Uuid[]) { + const record = this.getUndecryptables() + + for (const id of keyIds) { + delete record[id] + } + + this.persistUndecryptables(record) + } + + private getClientKeyParams() { + return this.protocolService.getAccountKeyParams() + } + + private async performServerSignIn(): Promise { + const accountPasswordChallenge = new Challenge( + [new ChallengePrompt(ChallengeValidation.None, undefined, undefined, true)], + ChallengeReason.Custom, + true, + KeyRecoveryStrings.KeyRecoveryLoginFlowReason, + ) + + const challengeResponse = await this.challengeService.promptForChallengeResponse(accountPasswordChallenge) + if (!challengeResponse) { + return undefined + } + + this.challengeService.completeChallenge(accountPasswordChallenge) + + const password = challengeResponse.values[0].value as string + + const clientParams = this.getClientKeyParams() as SNRootKeyParams + + const serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier) + + if (!serverParams || !serverKeyParamsAreSafe(serverParams, clientParams)) { + return + } + + const rootKey = await this.protocolService.computeRootKey(password, serverParams) + + const signInResponse = await this.userService.correctiveSignIn(rootKey) + + if (!signInResponse.error) { + void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced) + + return rootKey + } else { + await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryLoginFlowInvalidPassword) + + return this.performServerSignIn() + } + } + + private async getWrappingKeyIfApplicable(): Promise { + if (!this.protocolService.hasPasscode()) { + return undefined + } + const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable() + if (canceled) { + await this.alertService.alert( + KeyRecoveryStrings.KeyRecoveryPasscodeRequiredText, + KeyRecoveryStrings.KeyRecoveryPasscodeRequiredTitle, + ) + + return this.getWrappingKeyIfApplicable() + } + return wrappingKey + } + + private addKeysToQueue(keys: EncryptedPayloadInterface[]) { + for (const key of keys) { + const keyParams = this.protocolService.getKeyEmbeddedKeyParams(key) + if (!keyParams) { + continue + } + + const queueItem: DecryptionQueueItem = { + encryptedKey: key, + keyParams, + } + + this.decryptionQueue.push(queueItem) + } + } + + private readdQueueItem(queueItem: DecryptionQueueItem) { + this.decryptionQueue.unshift(queueItem) + } + + private async getLatestKeyParamsFromServer(identifier: string): Promise { + const paramsResponse = await this.apiService.getAccountKeyParams({ + email: identifier, + }) + + if (!paramsResponse.error && paramsResponse.data) { + return KeyParamsFromApiResponse(paramsResponse as KeyParamsResponse) + } else { + return undefined + } + } + + private async beginKeyRecovery() { + if (this.isProcessingQueue) { + return + } + + this.isProcessingQueue = true + + const clientParams = this.getClientKeyParams() + + let serverParams: SNRootKeyParams | undefined = undefined + if (clientParams) { + serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier) + } + + const deallocedAfterNetworkRequest = this.protocolService == undefined + if (deallocedAfterNetworkRequest) { + return + } + + const credentialsMissing = !this.protocolService.hasAccount() && !this.protocolService.hasPasscode() + + if (credentialsMissing) { + const rootKey = await this.performServerSignIn() + + if (rootKey) { + const replaceLocalRootKeyWithResult = true + await this.handleDecryptionOfAllKeysMatchingCorrectRootKey(rootKey, replaceLocalRootKeyWithResult, serverParams) + } + } + + await this.processQueue(serverParams) + + if (serverParams) { + await this.potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams) + } + + if (this.syncService.isOutOfSync()) { + void this.syncService.sync({ checkIntegrity: true }) + } + } + + private async potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams: SNRootKeyParams) { + const latestClientParamsAfterAllRecoveryOperations = this.getClientKeyParams() + + if (!latestClientParamsAfterAllRecoveryOperations) { + return + } + + const serverParamsDiffer = !serverParams.compare(latestClientParamsAfterAllRecoveryOperations) + + if (serverParamsDiffer && serverKeyParamsAreSafe(serverParams, latestClientParamsAfterAllRecoveryOperations)) { + await this.performServerSignIn() + } + } + + private async processQueue(serverParams?: SNRootKeyParams): Promise { + let queueItem = this.decryptionQueue[0] + + while (queueItem) { + const result = await this.processQueueItem(queueItem, serverParams) + + removeFromArray(this.decryptionQueue, queueItem) + + if (!isSuccessResult(result) && result.aborted) { + this.isProcessingQueue = false + + return + } + + queueItem = this.decryptionQueue[0] + } + + this.isProcessingQueue = false + } + + private async processQueueItem( + queueItem: DecryptionQueueItem, + serverParams?: SNRootKeyParams, + ): Promise { + const clientParams = this.getClientKeyParams() + + const operation = new KeyRecoveryOperation( + queueItem, + this.itemManager, + this.protocolService, + this.challengeService, + clientParams, + serverParams, + ) + + const result = await operation.run() + + if (!isSuccessResult(result)) { + if (!result.aborted) { + await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryUnableToRecover) + this.readdQueueItem(queueItem) + } + + return result + } + + await this.handleDecryptionOfAllKeysMatchingCorrectRootKey( + result.rootKey, + result.replaceLocalRootKeyWithResult, + serverParams, + ) + + return result + } + + private async handleDecryptionOfAllKeysMatchingCorrectRootKey( + rootKey: SNRootKey, + replacesRootKey: boolean, + serverParams?: SNRootKeyParams, + ): Promise { + if (replacesRootKey) { + const wrappingKey = await this.getWrappingKeyIfApplicable() + + await this.protocolService.setRootKey(rootKey, wrappingKey) + } + + const clientKeyParams = this.getClientKeyParams() + + const clientParamsMatchServer = clientKeyParams && serverParams && clientKeyParams.compare(serverParams) + + const matchingKeys = this.removeElementsFromQueueForMatchingKeyParams(rootKey.keyParams).map((qItem) => { + const needsResync = clientParamsMatchServer && !serverParams.compare(qItem.keyParams) + + return needsResync + ? qItem.encryptedKey.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() }) + : qItem.encryptedKey + }) + + const matchingResults = await this.protocolService.decryptSplit({ + usesRootKey: { + items: matchingKeys, + key: rootKey, + }, + }) + + const decryptedMatching = matchingResults.filter(isDecryptedPayload) + + void this.payloadManager.emitPayloads(decryptedMatching, PayloadEmitSource.LocalChanged) + + await this.storageService.savePayloads(decryptedMatching) + + if (replacesRootKey) { + void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced) + } else { + void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryKeyRecovered) + } + + if (decryptedMatching.some((p) => p.dirty)) { + await this.syncService.sync() + } + + await this.notifyEvent(KeyRecoveryEvent.KeysRecovered, decryptedMatching) + + void this.removeFromUndecryptables(Uuids(decryptedMatching)) + } + + private removeElementsFromQueueForMatchingKeyParams(keyParams: SNRootKeyParams) { + const matching = [] + const nonmatching = [] + + for (const queueItem of this.decryptionQueue) { + if (queueItem.keyParams.compare(keyParams)) { + matching.push(queueItem) + } else { + nonmatching.push(queueItem) + } + } + + this.decryptionQueue = nonmatching + + return matching + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + keyRecovery: { + queueLength: this.decryptionQueue.length, + isProcessingQueue: this.isProcessingQueue, + }, + }) + } +} diff --git a/packages/snjs/lib/Services/KeyRecovery/Types.ts b/packages/snjs/lib/Services/KeyRecovery/Types.ts new file mode 100644 index 000000000..fbb9d9be8 --- /dev/null +++ b/packages/snjs/lib/Services/KeyRecovery/Types.ts @@ -0,0 +1,36 @@ +import { SNRootKeyParams } from '@standardnotes/encryption' +import { + EncryptedTransferPayload, + EncryptedPayloadInterface, + DecryptedPayloadInterface, + ItemsKeyContent, + RootKeyInterface, +} from '@standardnotes/models' +import { UuidString } from '@Lib/Types' + +export type UndecryptableItemsStorage = Record + +export type KeyRecoveryOperationSuccessResult = { + rootKey: RootKeyInterface + decryptedItemsKey: DecryptedPayloadInterface + replaceLocalRootKeyWithResult: boolean +} + +export type KeyRecoveryOperationFailResult = { + aborted: boolean +} + +export type KeyRecoveryOperationResult = KeyRecoveryOperationSuccessResult | KeyRecoveryOperationFailResult + +export function isSuccessResult(x: KeyRecoveryOperationResult): x is KeyRecoveryOperationSuccessResult { + return 'rootKey' in x +} + +export type DecryptionQueueItem = { + encryptedKey: EncryptedPayloadInterface + keyParams: SNRootKeyParams +} + +export enum KeyRecoveryEvent { + KeysRecovered = 'KeysRecovered', +} diff --git a/packages/snjs/lib/Services/KeyRecovery/Utils.ts b/packages/snjs/lib/Services/KeyRecovery/Utils.ts new file mode 100644 index 000000000..24da2584e --- /dev/null +++ b/packages/snjs/lib/Services/KeyRecovery/Utils.ts @@ -0,0 +1,6 @@ +import { leftVersionGreaterThanOrEqualToRight } from '@standardnotes/common' +import { SNRootKeyParams } from '@standardnotes/encryption' + +export function serverKeyParamsAreSafe(serverParams: SNRootKeyParams, clientParams: SNRootKeyParams) { + return leftVersionGreaterThanOrEqualToRight(serverParams.version, clientParams.version) +} diff --git a/packages/snjs/lib/Services/Listed/ListedClientInterface.ts b/packages/snjs/lib/Services/Listed/ListedClientInterface.ts new file mode 100644 index 000000000..bd04e3d77 --- /dev/null +++ b/packages/snjs/lib/Services/Listed/ListedClientInterface.ts @@ -0,0 +1,9 @@ +import { Uuid } from '@standardnotes/common' +import { ListedAccount, ListedAccountInfo } from '@standardnotes/responses' + +export interface ListedClientInterface { + canRegisterNewListedAccount: () => boolean + requestNewListedAccount: () => Promise + getListedAccounts(): Promise + getListedAccountInfo(account: ListedAccount, inContextOfItem?: Uuid): Promise +} diff --git a/packages/snjs/lib/Services/Listed/ListedService.ts b/packages/snjs/lib/Services/Listed/ListedService.ts new file mode 100644 index 000000000..8a9b574d7 --- /dev/null +++ b/packages/snjs/lib/Services/Listed/ListedService.ts @@ -0,0 +1,121 @@ +import { isString, lastElement, sleep } from '@standardnotes/utils' +import { UuidString } from '@Lib/Types/UuidString' +import { ContentType } from '@standardnotes/common' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { SNHttpService } from '../Api/HttpService' +import { SettingName } from '@standardnotes/settings' +import { SNSettingsService } from '../Settings/SNSettingsService' +import { ListedClientInterface } from './ListedClientInterface' +import { SNApiService } from '../Api/ApiService' +import { ListedAccount, ListedAccountInfo, ListedAccountInfoResponse } from '@standardnotes/responses' +import { SNActionsExtension } from '@standardnotes/models' +import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' + +export class ListedService extends AbstractService implements ListedClientInterface { + constructor( + private apiService: SNApiService, + private itemManager: ItemManager, + private settingsService: SNSettingsService, + private httpSerivce: SNHttpService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + override deinit() { + ;(this.itemManager as unknown) = undefined + ;(this.settingsService as unknown) = undefined + ;(this.apiService as unknown) = undefined + ;(this.httpSerivce as unknown) = undefined + super.deinit() + } + + public canRegisterNewListedAccount(): boolean { + return this.apiService.user != undefined + } + + /** + * Account creation is asyncronous on the backend due to message-based nature of architecture. + * In order to get the newly created account, we poll the server to check for new accounts. + */ + public async requestNewListedAccount(): Promise { + const accountsBeforeRequest = await this.getSettingsBasedListedAccounts() + const response = await this.apiService.registerForListedAccount() + if (response.error) { + return undefined + } + const MaxAttempts = 4 + const DelayBetweenRequests = 3000 + for (let i = 0; i < MaxAttempts; i++) { + const accounts = await this.getSettingsBasedListedAccounts() + if (accounts.length > accountsBeforeRequest.length) { + return lastElement(accounts) + } else { + await sleep(DelayBetweenRequests, false) + } + } + return undefined + } + + public async getListedAccounts(): Promise { + const settingsBasedAccounts = await this.getSettingsBasedListedAccounts() + const legacyAccounts = this.getLegacyListedAccounts() + + return [...settingsBasedAccounts, ...legacyAccounts] + } + + public async getListedAccountInfo( + account: ListedAccount, + inContextOfItem?: UuidString, + ): Promise { + const hostUrl = account.hostUrl + let url = `${hostUrl}/authors/${account.authorId}/extension?secret=${account.secret}` + if (inContextOfItem) { + url += `&item_uuid=${inContextOfItem}` + } + const response = (await this.httpSerivce.getAbsolute(url)) as ListedAccountInfoResponse + if (response.error || !response.data || isString(response.data)) { + return undefined + } + + return response.data + } + + private async getSettingsBasedListedAccounts(): Promise { + const response = await this.settingsService.getSetting(SettingName.ListedAuthorSecrets) + if (!response) { + return [] + } + const accounts = JSON.parse(response) as ListedAccount[] + return accounts + } + + private getLegacyListedAccounts(): ListedAccount[] { + const extensions = this.itemManager + .getItems(ContentType.ActionsExtension) + .filter((extension) => extension.isListedExtension) + + const accounts: ListedAccount[] = [] + + for (const extension of extensions) { + const urlString = extension.url + const url = new URL(urlString) + + /** Expected path format: '/authors/647/extension/' */ + const path = url.pathname + const authorId = path.split('/')[2] + + /** Expected query string format: '?secret=xxx&type=sn&name=Listed' */ + const queryString = url.search + const key = queryString.split('secret=')[1].split('&')[0] + + accounts.push({ + secret: key, + authorId, + hostUrl: url.origin, + }) + } + + return accounts + } +} diff --git a/packages/snjs/lib/Services/Listed/index.ts b/packages/snjs/lib/Services/Listed/index.ts new file mode 100644 index 000000000..c75d3bbe8 --- /dev/null +++ b/packages/snjs/lib/Services/Listed/index.ts @@ -0,0 +1,2 @@ +export * from './ListedClientInterface' +export * from './ListedService' diff --git a/packages/snjs/lib/Services/Mfa/MfaService.ts b/packages/snjs/lib/Services/Mfa/MfaService.ts new file mode 100644 index 000000000..74935d1fe --- /dev/null +++ b/packages/snjs/lib/Services/Mfa/MfaService.ts @@ -0,0 +1,68 @@ +import { SettingName } from '@standardnotes/settings' + +import { SNSettingsService } from '../Settings' +import * as messages from '../Api/Messages' +import { PureCryptoInterface } from '@standardnotes/sncrypto-common' +import { SNFeaturesService } from '../Features/FeaturesService' +import { FeatureIdentifier } from '@standardnotes/features' +import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' + +export class SNMfaService extends AbstractService { + constructor( + private settingsService: SNSettingsService, + private crypto: PureCryptoInterface, + private featuresService: SNFeaturesService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + private async saveMfaSetting(secret: string): Promise { + return await this.settingsService.updateSetting(SettingName.MfaSecret, secret, true) + } + + async isMfaActivated(): Promise { + const mfaSetting = await this.settingsService.getDoesSensitiveSettingExist(SettingName.MfaSecret) + return mfaSetting != false + } + + async generateMfaSecret(): Promise { + return this.crypto.generateOtpSecret() + } + + async getOtpToken(secret: string): Promise { + return this.crypto.totpToken(secret, Date.now(), 6, 30) + } + + async enableMfa(secret: string, otpToken: string): Promise { + const otpTokenValid = otpToken != undefined && otpToken === (await this.getOtpToken(secret)) + + if (!otpTokenValid) { + throw new Error(messages.SignInStrings.IncorrectMfa) + } + + return this.saveMfaSetting(secret) + } + + async disableMfa(): Promise { + return await this.settingsService.deleteSetting(SettingName.MfaSecret) + } + + isMfaFeatureAvailable(): boolean { + const feature = this.featuresService.getUserFeature(FeatureIdentifier.TwoFactorAuth) + + // If the feature is not present in the collection, we don't want to block it + if (feature == undefined) { + return false + } + + return feature.no_expire === true || (feature.expires_at ?? 0) > Date.now() + } + + override deinit(): void { + ;(this.settingsService as unknown) = undefined + ;(this.crypto as unknown) = undefined + ;(this.featuresService as unknown) = undefined + super.deinit() + } +} diff --git a/packages/snjs/lib/Services/Migration/MigrationService.ts b/packages/snjs/lib/Services/Migration/MigrationService.ts new file mode 100644 index 000000000..5f680ca5e --- /dev/null +++ b/packages/snjs/lib/Services/Migration/MigrationService.ts @@ -0,0 +1,151 @@ +import { ApplicationEvent } from '../../Application/Event' +import { BaseMigration } from '@Lib/Migrations/Base' +import { compareSemVersions } from '@Lib/Version' +import { lastElement } from '@standardnotes/utils' +import { Migration } from '@Lib/Migrations/Migration' +import { MigrationServices } from '../../Migrations/MigrationServices' +import { + RawStorageKey, + namespacedKey, + ApplicationStage, + AbstractService, + DiagnosticInfo, +} from '@standardnotes/services' +import { SnjsVersion, isRightVersionGreaterThanLeft } from '../../Version' +import { SNLog } from '@Lib/Log' +import { MigrationClasses } from '@Lib/Migrations/Versions' + +/** + * The migration service orchestrates the execution of multi-stage migrations. + * Migrations are registered during initial application launch, and listen for application + * life-cycle events, and act accordingly. Migrations operate on the app-level, and not global level. + * For example, a single migration may perform a unique set of steps when the application + * first launches, and also other steps after the application is unlocked, or after the + * first sync completes. Migrations live under /migrations and inherit from the base Migration class. + */ +export class SNMigrationService extends AbstractService { + private activeMigrations?: Migration[] + private baseMigration!: BaseMigration + + constructor(private services: MigrationServices) { + super(services.internalEventBus) + } + + override deinit(): void { + ;(this.services as unknown) = undefined + + if (this.activeMigrations) { + this.activeMigrations.length = 0 + } + + super.deinit() + } + + public async initialize(): Promise { + await this.runBaseMigrationPreRun() + + const requiredMigrations = SNMigrationService.getRequiredMigrations(await this.getStoredSnjsVersion()) + + this.activeMigrations = this.instantiateMigrationClasses(requiredMigrations) + + if (this.activeMigrations.length > 0) { + const lastMigration = lastElement(this.activeMigrations) as Migration + lastMigration.onDone(async () => { + await this.markMigrationsAsDone() + }) + } else { + await this.services.deviceInterface.setRawStorageValue( + namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion), + SnjsVersion, + ) + } + } + + private async markMigrationsAsDone() { + await this.services.deviceInterface.setRawStorageValue( + namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion), + SnjsVersion, + ) + } + + private async runBaseMigrationPreRun() { + this.baseMigration = new BaseMigration(this.services) + await this.baseMigration.preRun() + } + + /** + * Application instances will call this function directly when they arrive + * at a certain migratory state. + */ + public override async handleApplicationStage(stage: ApplicationStage): Promise { + await super.handleApplicationStage(stage) + await this.handleStage(stage) + } + + /** + * Called by application + */ + public async handleApplicationEvent(event: ApplicationEvent): Promise { + if (event === ApplicationEvent.SignedIn) { + await this.handleStage(ApplicationStage.SignedIn_30) + } + } + + public async hasPendingMigrations(): Promise { + const requiredMigrations = SNMigrationService.getRequiredMigrations(await this.getStoredSnjsVersion()) + return requiredMigrations.length > 0 || (await this.baseMigration.needsKeychainRepair()) + } + + public async getStoredSnjsVersion(): Promise { + const version = await this.services.deviceInterface.getRawStorageValue( + namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion), + ) + if (!version) { + throw SNLog.error(Error('Snjs version missing from storage, run base migration.')) + } + return version + } + + private static getRequiredMigrations(storedVersion: string) { + const resultingClasses = [] + const sortedClasses = MigrationClasses.sort((a, b) => { + return compareSemVersions(a.version(), b.version()) + }) + for (const migrationClass of sortedClasses) { + const migrationVersion = migrationClass.version() + if (migrationVersion === storedVersion) { + continue + } + if (isRightVersionGreaterThanLeft(storedVersion, migrationVersion)) { + resultingClasses.push(migrationClass) + } + } + return resultingClasses + } + + private instantiateMigrationClasses(classes: typeof MigrationClasses): Migration[] { + return classes.map((migrationClass) => { + return new migrationClass(this.services) + }) + } + + private async handleStage(stage: ApplicationStage) { + await this.baseMigration.handleStage(stage) + + if (!this.activeMigrations) { + throw new Error('Invalid active migrations') + } + + for (const migration of this.activeMigrations) { + await migration.handleStage(stage) + } + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + migrations: { + activeMigrations: this.activeMigrations && this.activeMigrations.map((m) => typeof m), + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Mutator/MutatorClientInterface.ts b/packages/snjs/lib/Services/Mutator/MutatorClientInterface.ts new file mode 100644 index 000000000..e099405ff --- /dev/null +++ b/packages/snjs/lib/Services/Mutator/MutatorClientInterface.ts @@ -0,0 +1,183 @@ +import { ContentType } from '@standardnotes/common' +import { ChallengeReason, SyncOptions } from '@standardnotes/services' +import { TransactionalMutation } from '../Items' +import * as Models from '@standardnotes/models' +import { ClientDisplayableError } from '@standardnotes/responses' +import { BackupFile } from '@standardnotes/encryption' + +export interface MutatorClientInterface { + /** + * Inserts the input item by its payload properties, and marks the item as dirty. + * A sync is not performed after an item is inserted. This must be handled by the caller. + */ + insertItem(item: Models.DecryptedItemInterface): Promise + + /** + * Mutates a pre-existing item, marks it as dirty, and syncs it + */ + changeAndSaveItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + emitSource?: Models.PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise + + /** + * Mutates pre-existing items, marks them as dirty, and syncs + */ + changeAndSaveItems( + itemsToLookupUuidsFor: Models.DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + emitSource?: Models.PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise + + /** + * Mutates a pre-existing item and marks it as dirty. Does not sync changes. + */ + changeItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + ): Promise + + /** + * Mutates a pre-existing items and marks them as dirty. Does not sync changes. + */ + changeItems( + itemsToLookupUuidsFor: Models.DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps?: boolean, + ): Promise<(Models.DecryptedItemInterface | undefined)[]> + + /** + * Run unique mutations per each item in the array, then only propagate all changes + * once all mutations have been run. This differs from `changeItems` in that changeItems + * runs the same mutation on all items. + */ + runTransactionalMutations( + transactions: TransactionalMutation[], + emitSource?: Models.PayloadEmitSource, + payloadSourceKey?: string, + ): Promise<(Models.DecryptedItemInterface | undefined)[]> + + runTransactionalMutation( + transaction: TransactionalMutation, + emitSource?: Models.PayloadEmitSource, + payloadSourceKey?: string, + ): Promise + + protectItems< + _M extends Models.DecryptedItemMutator, + I extends Models.DecryptedItemInterface, + >( + items: I[], + ): Promise + + unprotectItems< + _M extends Models.DecryptedItemMutator, + I extends Models.DecryptedItemInterface, + >( + items: I[], + reason: ChallengeReason, + ): Promise + + protectNote(note: Models.SNNote): Promise + + unprotectNote(note: Models.SNNote): Promise + + protectNotes(notes: Models.SNNote[]): Promise + + unprotectNotes(notes: Models.SNNote[]): Promise + + protectFile(file: Models.FileItem): Promise + + unprotectFile(file: Models.FileItem): Promise + + /** + * Takes the values of the input item and emits it onto global state. + */ + mergeItem( + item: Models.DecryptedItemInterface, + source: Models.PayloadEmitSource, + ): Promise + + /** + * Creates an unmanaged item that can be added later. + */ + createTemplateItem< + C extends Models.ItemContent = Models.ItemContent, + I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, + >( + contentType: ContentType, + content?: C, + ): I + + /** + * @param isUserModified Whether to change the modified date the user + * sees of the item. + */ + setItemNeedsSync( + item: Models.DecryptedItemInterface, + isUserModified?: boolean, + ): Promise + + setItemsNeedsSync(items: Models.DecryptedItemInterface[]): Promise<(Models.DecryptedItemInterface | undefined)[]> + + deleteItem(item: Models.DecryptedItemInterface | Models.EncryptedItemInterface): Promise + + deleteItems(items: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[]): Promise + + emptyTrash(): Promise + + duplicateItem(item: T, additionalContent?: Partial): Promise + + /** + * Migrates any tags containing a '.' character to sa chema-based heirarchy, removing + * the dot from the tag's title. + */ + migrateTagsToFolders(): Promise + + /** + * Establishes a hierarchical relationship between two tags. + */ + setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise + + /** + * Remove the tag parent. + */ + unsetTagParent(childTag: Models.SNTag): Promise + + findOrCreateTag(title: string): Promise + + /** Creates and returns the tag but does not run sync. Callers must perform sync. */ + createTagOrSmartView(title: string): Promise + + /** + * Activates or deactivates a component, depending on its + * current state, and syncs. + */ + toggleComponent(component: Models.SNComponent): Promise + + toggleTheme(theme: Models.SNComponent): Promise + + /** + * @returns + * .affectedItems: Items that were either created or dirtied by this import + * .errorCount: The number of items that were not imported due to failure to decrypt. + */ + importData( + data: BackupFile, + awaitSync?: boolean, + ): Promise< + | { + affectedItems: Models.DecryptedItemInterface[] + errorCount: number + } + | { + error: ClientDisplayableError + } + > +} diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts b/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts new file mode 100644 index 000000000..1d21c0063 --- /dev/null +++ b/packages/snjs/lib/Services/Mutator/MutatorService.spec.ts @@ -0,0 +1,83 @@ +import { SNHistoryManager } from './../History/HistoryManager' +import { NoteContent, SNNote, FillItemContent, DecryptedPayload, PayloadTimestampDefaults } from '@standardnotes/models' +import { EncryptionService } from '@standardnotes/encryption' +import { ContentType } from '@standardnotes/common' +import { InternalEventBusInterface } from '@standardnotes/services' +import { + ChallengeService, + MutatorService, + PayloadManager, + SNComponentManager, + SNProtectionService, + ItemManager, + SNSyncService, +} from '../' +import { UuidGenerator } from '@standardnotes/utils' + +const setupRandomUuid = () => { + UuidGenerator.SetGenerator(() => String(Math.random())) +} + +describe('mutator service', () => { + let mutatorService: MutatorService + let payloadManager: PayloadManager + let itemManager: ItemManager + let syncService: SNSyncService + let protectionService: SNProtectionService + let protocolService: EncryptionService + let challengeService: ChallengeService + let componentManager: SNComponentManager + let historyService: SNHistoryManager + + let internalEventBus: InternalEventBusInterface + + beforeEach(() => { + setupRandomUuid() + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + + payloadManager = new PayloadManager(internalEventBus) + itemManager = new ItemManager(payloadManager, { supportsFileNavigation: false }, internalEventBus) + + mutatorService = new MutatorService( + itemManager, + syncService, + protectionService, + protocolService, + payloadManager, + challengeService, + componentManager, + historyService, + internalEventBus, + ) + }) + + const insertNote = (title: string) => { + const note = new SNNote( + new DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.Note, + content: FillItemContent({ + title: title, + }), + ...PayloadTimestampDefaults(), + }), + ) + return mutatorService.insertItem(note) + } + + describe('note modifications', () => { + it('pinning should not update timestamps', async () => { + const note = await insertNote('hello') + const pinnedNote = await mutatorService.changeItem( + note, + (mutator) => { + mutator.pinned = true + }, + false, + ) + + expect(note.userModifiedDate).toEqual(pinnedNote?.userModifiedDate) + }) + }) +}) diff --git a/packages/snjs/lib/Services/Mutator/MutatorService.ts b/packages/snjs/lib/Services/Mutator/MutatorService.ts new file mode 100644 index 000000000..55ba1c274 --- /dev/null +++ b/packages/snjs/lib/Services/Mutator/MutatorService.ts @@ -0,0 +1,386 @@ +import { SNHistoryManager } from './../History/HistoryManager' +import { + AbstractService, + InternalEventBusInterface, + SyncOptions, + ChallengeValidation, + ChallengePrompt, + ChallengeReason, +} from '@standardnotes/services' +import { BackupFile, EncryptionProvider } from '@standardnotes/encryption' +import { ClientDisplayableError } from '@standardnotes/responses' +import { ContentType, ProtocolVersion, compareVersions } from '@standardnotes/common' +import { ItemManager, TransactionalMutation } from '../Items' +import { MutatorClientInterface } from './MutatorClientInterface' +import { PayloadManager } from '../Payloads/PayloadManager' +import { SNComponentManager } from '../ComponentManager/ComponentManager' +import { SNProtectionService } from '../Protection/ProtectionService' +import { SNSyncService } from '../Sync' +import { Strings } from '../../Strings' +import { TagsToFoldersMigrationApplicator } from '@Lib/Migrations/Applicators/TagsToFolders' +import * as Models from '@standardnotes/models' +import { Challenge, ChallengeService } from '../Challenge' +import { + CreateDecryptedBackupFileContextPayload, + CreateEncryptedBackupFileContextPayload, + isDecryptedPayload, + isEncryptedTransferPayload, +} from '@standardnotes/models' + +export class MutatorService extends AbstractService implements MutatorClientInterface { + constructor( + private itemManager: ItemManager, + private syncService: SNSyncService, + private protectionService: SNProtectionService, + private encryption: EncryptionProvider, + private payloadManager: PayloadManager, + private challengeService: ChallengeService, + private componentManager: SNComponentManager, + private historyService: SNHistoryManager, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + override deinit() { + super.deinit() + ;(this.itemManager as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.protectionService as unknown) = undefined + ;(this.encryption as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.componentManager as unknown) = undefined + ;(this.historyService as unknown) = undefined + } + + public async insertItem(item: Models.DecryptedItemInterface): Promise { + const mutator = Models.CreateDecryptedMutatorForItem(item, Models.MutationType.UpdateUserTimestamps) + const dirtiedPayload = mutator.getResult() + const insertedItem = await this.itemManager.emitItemFromPayload( + dirtiedPayload, + Models.PayloadEmitSource.LocalInserted, + ) + return insertedItem + } + + public async changeAndSaveItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps = true, + emitSource?: Models.PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise { + await this.itemManager.changeItems( + [itemToLookupUuidFor], + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + emitSource, + ) + await this.syncService.sync(syncOptions) + return this.itemManager.findItem(itemToLookupUuidFor.uuid) + } + + public async changeAndSaveItems( + itemsToLookupUuidsFor: Models.DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps = true, + emitSource?: Models.PayloadEmitSource, + syncOptions?: SyncOptions, + ): Promise { + await this.itemManager.changeItems( + itemsToLookupUuidsFor, + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + emitSource, + ) + await this.syncService.sync(syncOptions) + } + + public async changeItem( + itemToLookupUuidFor: Models.DecryptedItemInterface, + mutate: (mutator: M) => void, + updateTimestamps = true, + ): Promise { + await this.itemManager.changeItems( + [itemToLookupUuidFor], + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + ) + return this.itemManager.findItem(itemToLookupUuidFor.uuid) + } + + public async changeItems( + itemsToLookupUuidsFor: Models.DecryptedItemInterface[], + mutate: (mutator: M) => void, + updateTimestamps = true, + ): Promise<(Models.DecryptedItemInterface | undefined)[]> { + return this.itemManager.changeItems( + itemsToLookupUuidsFor, + mutate, + updateTimestamps ? Models.MutationType.UpdateUserTimestamps : Models.MutationType.NoUpdateUserTimestamps, + ) + } + + public async runTransactionalMutations( + transactions: TransactionalMutation[], + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise<(Models.DecryptedItemInterface | undefined)[]> { + return this.itemManager.runTransactionalMutations(transactions, emitSource, payloadSourceKey) + } + + public async runTransactionalMutation( + transaction: TransactionalMutation, + emitSource = Models.PayloadEmitSource.LocalChanged, + payloadSourceKey?: string, + ): Promise { + return this.itemManager.runTransactionalMutation(transaction, emitSource, payloadSourceKey) + } + + async protectItems( + items: I[], + ): Promise { + const protectedItems = await this.itemManager.changeItems( + items, + (mutator) => { + mutator.protected = true + }, + Models.MutationType.NoUpdateUserTimestamps, + ) + + void this.syncService.sync() + return protectedItems + } + + async unprotectItems( + items: I[], + reason: ChallengeReason, + ): Promise { + if (!(await this.protectionService.authorizeAction(reason))) { + return undefined + } + + const unprotectedItems = await this.itemManager.changeItems( + items, + (mutator) => { + mutator.protected = false + }, + Models.MutationType.NoUpdateUserTimestamps, + ) + + void this.syncService.sync() + return unprotectedItems + } + + public async protectNote(note: Models.SNNote): Promise { + const result = await this.protectItems([note]) + return result[0] + } + + public async unprotectNote(note: Models.SNNote): Promise { + const result = await this.unprotectItems([note], ChallengeReason.UnprotectNote) + return result ? result[0] : undefined + } + + public async protectNotes(notes: Models.SNNote[]): Promise { + return this.protectItems(notes) + } + + public async unprotectNotes(notes: Models.SNNote[]): Promise { + const results = await this.unprotectItems(notes, ChallengeReason.UnprotectNote) + return results || [] + } + + async protectFile(file: Models.FileItem): Promise { + const result = await this.protectItems([file]) + return result[0] + } + + async unprotectFile(file: Models.FileItem): Promise { + const result = await this.unprotectItems([file], ChallengeReason.UnprotectFile) + return result ? result[0] : undefined + } + + public async mergeItem( + item: Models.DecryptedItemInterface, + source: Models.PayloadEmitSource, + ): Promise { + return this.itemManager.emitItemFromPayload(item.payloadRepresentation(), source) + } + + public createTemplateItem< + C extends Models.ItemContent = Models.ItemContent, + I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface, + >(contentType: ContentType, content?: C): I { + return this.itemManager.createTemplateItem(contentType, content) + } + + public async setItemNeedsSync( + item: Models.DecryptedItemInterface, + updateTimestamps = false, + ): Promise { + return this.itemManager.setItemDirty(item, updateTimestamps) + } + + public async setItemsNeedsSync( + items: Models.DecryptedItemInterface[], + ): Promise<(Models.DecryptedItemInterface | undefined)[]> { + return this.itemManager.setItemsDirty(items) + } + + public async deleteItem(item: Models.DecryptedItemInterface | Models.EncryptedItemInterface): Promise { + return this.deleteItems([item]) + } + + public async deleteItems(items: (Models.DecryptedItemInterface | Models.EncryptedItemInterface)[]): Promise { + await this.itemManager.setItemsToBeDeleted(items) + await this.syncService.sync() + } + + public async emptyTrash(): Promise { + await this.itemManager.emptyTrash() + await this.syncService.sync() + } + + public duplicateItem( + item: T, + additionalContent?: Partial, + ): Promise { + const duplicate = this.itemManager.duplicateItem(item, false, additionalContent) + void this.syncService.sync() + return duplicate + } + + public async migrateTagsToFolders(): Promise { + await TagsToFoldersMigrationApplicator.run(this.itemManager) + return this.syncService.sync() + } + + public async setTagParent(parentTag: Models.SNTag, childTag: Models.SNTag): Promise { + await this.itemManager.setTagParent(parentTag, childTag) + } + + public async unsetTagParent(childTag: Models.SNTag): Promise { + await this.itemManager.unsetTagParent(childTag) + } + + public async findOrCreateTag(title: string): Promise { + return this.itemManager.findOrCreateTagByTitle(title) + } + + /** Creates and returns the tag but does not run sync. Callers must perform sync. */ + public async createTagOrSmartView(title: string): Promise { + return this.itemManager.createTagOrSmartView(title) + } + + public async toggleComponent(component: Models.SNComponent): Promise { + await this.componentManager.toggleComponent(component.uuid) + await this.syncService.sync() + } + + public async toggleTheme(theme: Models.SNComponent): Promise { + await this.componentManager.toggleTheme(theme.uuid) + await this.syncService.sync() + } + + public async importData( + data: BackupFile, + awaitSync = false, + ): Promise< + | { + affectedItems: Models.DecryptedItemInterface[] + errorCount: number + } + | { + error: ClientDisplayableError + } + > { + if (data.version) { + /** + * Prior to 003 backup files did not have a version field so we cannot + * stop importing if there is no backup file version, only if there is + * an unsupported version. + */ + const version = data.version as ProtocolVersion + + const supportedVersions = this.encryption.supportedVersions() + if (!supportedVersions.includes(version)) { + return { error: new ClientDisplayableError(Strings.Info.UnsupportedBackupFileVersion) } + } + + const userVersion = this.encryption.getUserVersion() + if (userVersion && compareVersions(version, userVersion) === 1) { + /** File was made with a greater version than the user's account */ + return { error: new ClientDisplayableError(Strings.Info.BackupFileMoreRecentThanAccount) } + } + } + + let password: string | undefined + + if (data.auth_params || data.keyParams) { + /** Get import file password. */ + const challenge = new Challenge( + [new ChallengePrompt(ChallengeValidation.None, Strings.Input.FileAccountPassword, undefined, true)], + ChallengeReason.DecryptEncryptedFile, + true, + ) + const passwordResponse = await this.challengeService.promptForChallengeResponse(challenge) + if (passwordResponse == undefined) { + /** Challenge was canceled */ + return { error: new ClientDisplayableError('Import aborted') } + } + this.challengeService.completeChallenge(challenge) + password = passwordResponse?.values[0].value as string + } + + if (!(await this.protectionService.authorizeFileImport())) { + return { error: new ClientDisplayableError('Import aborted') } + } + + data.items = data.items.map((item) => { + if (isEncryptedTransferPayload(item)) { + return CreateEncryptedBackupFileContextPayload(item) + } else { + return CreateDecryptedBackupFileContextPayload(item as Models.BackupFileDecryptedContextualPayload) + } + }) + + const decryptedPayloadsOrError = await this.encryption.decryptBackupFile(data, password) + + if (decryptedPayloadsOrError instanceof ClientDisplayableError) { + return { error: decryptedPayloadsOrError } + } + + const validPayloads = decryptedPayloadsOrError.filter(isDecryptedPayload).map((payload) => { + /* Don't want to activate any components during import process in + * case of exceptions breaking up the import proccess */ + if (payload.content_type === ContentType.Component && (payload.content as Models.ComponentContent).active) { + const typedContent = payload as Models.DecryptedPayloadInterface + return Models.CopyPayloadWithContentOverride(typedContent, { + active: false, + }) + } else { + return payload + } + }) + + const affectedUuids = await this.payloadManager.importPayloads( + validPayloads, + this.historyService.getHistoryMapCopy(), + ) + + const promise = this.syncService.sync() + + if (awaitSync) { + await promise + } + + const affectedItems = this.itemManager.findItems(affectedUuids) as Models.DecryptedItemInterface[] + + return { + affectedItems: affectedItems, + errorCount: decryptedPayloadsOrError.length - validPayloads.length, + } + } +} diff --git a/packages/snjs/lib/Services/Mutator/index.ts b/packages/snjs/lib/Services/Mutator/index.ts new file mode 100644 index 000000000..ae73aaa1c --- /dev/null +++ b/packages/snjs/lib/Services/Mutator/index.ts @@ -0,0 +1,2 @@ +export * from './MutatorClientInterface' +export * from './MutatorService' diff --git a/packages/snjs/lib/Services/Payloads/PayloadManager.spec.ts b/packages/snjs/lib/Services/Payloads/PayloadManager.spec.ts new file mode 100644 index 000000000..1da81e662 --- /dev/null +++ b/packages/snjs/lib/Services/Payloads/PayloadManager.spec.ts @@ -0,0 +1,53 @@ +import { + DecryptedPayload, + FillItemContent, + ItemsKeyContent, + PayloadEmitSource, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { PayloadManager } from './PayloadManager' +import { InternalEventBusInterface } from '@standardnotes/services' +import { ContentType } from '@standardnotes/common' + +describe('payload manager', () => { + let payloadManager: PayloadManager + let internalEventBus: InternalEventBusInterface + + beforeEach(() => { + internalEventBus = {} as jest.Mocked + internalEventBus.publish = jest.fn() + + payloadManager = new PayloadManager(internalEventBus) + }) + + it('emitting a payload should emit as-is and not merge on top of existing payload', async () => { + const decrypted = new DecryptedPayload({ + uuid: '123', + content_type: ContentType.ItemsKey, + content: FillItemContent({ + itemsKey: 'secret', + }), + ...PayloadTimestampDefaults(), + updated_at_timestamp: 1, + dirty: true, + }) + + await payloadManager.emitPayload(decrypted, PayloadEmitSource.LocalInserted) + + const nondirty = new DecryptedPayload({ + uuid: '123', + content_type: ContentType.ItemsKey, + ...PayloadTimestampDefaults(), + updated_at_timestamp: 2, + content: FillItemContent({ + itemsKey: 'secret', + }), + }) + + await payloadManager.emitPayload(nondirty, PayloadEmitSource.LocalChanged) + + const result = payloadManager.findOne('123') + + expect(result?.dirty).toBeFalsy() + }) +}) diff --git a/packages/snjs/lib/Services/Payloads/PayloadManager.ts b/packages/snjs/lib/Services/Payloads/PayloadManager.ts new file mode 100644 index 000000000..d7b086cf0 --- /dev/null +++ b/packages/snjs/lib/Services/Payloads/PayloadManager.ts @@ -0,0 +1,338 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { PayloadsChangeObserver, QueueElement, PayloadsChangeObserverCallback, EmitQueue } from './Types' +import { removeFromArray, Uuids } from '@standardnotes/utils' +import { + DeltaFileImport, + isDeletedPayload, + ImmutablePayloadCollection, + EncryptedPayloadInterface, + PayloadSource, + DeletedPayloadInterface, + DecryptedPayloadInterface, + PayloadCollection, + PayloadEmitSource, + DeletedPayload, + FullyFormedPayloadInterface, + isEncryptedPayload, + isDecryptedPayload, + HistoryMap, + DeltaEmit, + getIncrementedDirtyIndex, +} from '@standardnotes/models' +import { + AbstractService, + PayloadManagerInterface, + InternalEventBusInterface, + DiagnosticInfo, +} from '@standardnotes/services' +import { IntegrityPayload } from '@standardnotes/responses' + +/** + * The payload manager is responsible for keeping state regarding what items exist in the + * global application state. It does so by exposing functions that allow consumers to 'map' + * a detached payload into global application state. Whenever a change is made or retrieved + * from any source, it must be mapped in order to be properly reflected in global application state. + * The model manager deals only with in-memory state, and does not deal directly with storage. + * It also serves as a query store, and can be queried for current notes, tags, etc. + * It exposes methods that allow consumers to listen to mapping events. This is how + * applications 'stream' items to display in the interface. + */ +export class PayloadManager extends AbstractService implements PayloadManagerInterface { + private changeObservers: PayloadsChangeObserver[] = [] + public collection: PayloadCollection + private emitQueue: EmitQueue = [] + + constructor(protected override internalEventBus: InternalEventBusInterface) { + super(internalEventBus) + this.collection = new PayloadCollection() + } + + /** + * Our payload collection keeps the latest mapped payload for every payload + * that passes through our mapping function. Use this to query current state + * as needed to make decisions, like about duplication or uuid alteration. + */ + public getMasterCollection() { + return ImmutablePayloadCollection.FromCollection(this.collection) + } + + public override deinit() { + super.deinit() + this.changeObservers.length = 0 + this.resetState() + } + + public resetState() { + this.collection = new PayloadCollection() + } + + public find(uuids: Uuid[]): FullyFormedPayloadInterface[] { + return this.collection.findAll(uuids) + } + + public findOne(uuid: Uuid): FullyFormedPayloadInterface | undefined { + return this.collection.findAll([uuid])[0] + } + + public all(contentType: ContentType): FullyFormedPayloadInterface[] { + return this.collection.all(contentType) + } + + public get integrityPayloads(): IntegrityPayload[] { + return this.collection.integrityPayloads() + } + + public get nonDeletedItems(): FullyFormedPayloadInterface[] { + return this.collection.nondeletedElements() + } + + public get invalidPayloads(): EncryptedPayloadInterface[] { + return this.collection.invalidElements() + } + + public async emitDeltaEmit

( + emit: DeltaEmit

, + sourceKey?: string, + ): Promise { + if (emit.emits.length === 0 && emit.ignored?.length === 0) { + return [] + } + + return new Promise((resolve) => { + const element: QueueElement

= { + emit: emit, + sourceKey, + resolve, + } + + this.emitQueue.push(element as unknown as QueueElement) + + if (this.emitQueue.length === 1) { + void this.popQueue() + } + }) + } + + /** + * One of many mapping helpers available. + * This function maps a payload to an item + * @returns every paylod altered as a result of this operation, to be + * saved to storage by the caller + */ + public async emitPayload

( + payload: P, + source: PayloadEmitSource, + sourceKey?: string, + ): Promise { + return this.emitPayloads([payload], source, sourceKey) + } + + /** + * This function maps multiple payloads to items, and is the authoratative mapping + * function that all other mapping helpers rely on + * @returns every paylod altered as a result of this operation, to be + * saved to storage by the caller + */ + public async emitPayloads

( + payloads: P[], + source: PayloadEmitSource, + sourceKey?: string, + ): Promise { + const emit: DeltaEmit

= { + emits: payloads, + source: source, + } + + return this.emitDeltaEmit(emit, sourceKey) + } + + private popQueue() { + const first = this.emitQueue[0] + + const { changed, inserted, discarded, unerrored } = this.applyPayloads(first.emit.emits) + + this.notifyChangeObservers( + changed, + inserted, + discarded, + first.emit.ignored || [], + unerrored, + first.emit.source, + first.sourceKey, + ) + + removeFromArray(this.emitQueue, first) + + first.resolve([...changed, ...inserted, ...discarded]) + + if (this.emitQueue.length > 0) { + void this.popQueue() + } + } + + private applyPayloads(applyPayloads: FullyFormedPayloadInterface[]) { + const changed: FullyFormedPayloadInterface[] = [] + const inserted: FullyFormedPayloadInterface[] = [] + const discarded: DeletedPayloadInterface[] = [] + const unerrored: DecryptedPayloadInterface[] = [] + + for (const apply of applyPayloads) { + if (!apply.uuid || !apply.content_type) { + console.error('Payload is corrupt', apply) + + continue + } + + this.log( + 'applying payload', + apply.uuid, + 'globalDirtyIndexAtLastSync', + apply.globalDirtyIndexAtLastSync, + 'dirtyIndex', + apply.dirtyIndex, + 'dirty', + apply.dirty, + ) + + const base = this.collection.find(apply.uuid) + + if (isDeletedPayload(apply) && apply.discardable) { + this.collection.discard(apply) + + discarded.push(apply) + } else { + this.collection.set(apply) + + if (base) { + changed.push(apply) + + if (isEncryptedPayload(base) && isDecryptedPayload(apply)) { + unerrored.push(apply) + } + } else { + inserted.push(apply) + } + } + } + + return { changed, inserted, discarded, unerrored } + } + + /** + * Notifies observers when an item has been mapped. + * @param types - An array of content types to listen for + * @param priority - The lower the priority, the earlier the function is called + * wrt to other observers + */ + public addObserver(types: ContentType | ContentType[], callback: PayloadsChangeObserverCallback, priority = 1) { + if (!Array.isArray(types)) { + types = [types] + } + const observer: PayloadsChangeObserver = { + types, + priority, + callback, + } + this.changeObservers.push(observer) + + const thislessChangeObservers = this.changeObservers + return () => { + removeFromArray(thislessChangeObservers, observer) + } + } + + /** + * This function is mostly for internal use, but can be used externally by consumers who + * explicitely understand what they are doing (want to propagate model state without mapping) + */ + public notifyChangeObservers( + changed: FullyFormedPayloadInterface[], + inserted: FullyFormedPayloadInterface[], + discarded: DeletedPayloadInterface[], + ignored: EncryptedPayloadInterface[], + unerrored: DecryptedPayloadInterface[], + source: PayloadEmitSource, + sourceKey?: string, + ) { + /** Slice the observers array as sort modifies in-place */ + const observers = this.changeObservers.slice().sort((a, b) => { + return a.priority < b.priority ? -1 : 1 + }) + + const filter =

( + payloads: P[], + types: ContentType[], + ) => { + return types.includes(ContentType.Any) + ? payloads.slice() + : payloads.slice().filter((payload) => { + return types.includes(payload.content_type) + }) + } + + for (const observer of observers) { + observer.callback({ + changed: filter(changed, observer.types), + inserted: filter(inserted, observer.types), + discarded: filter(discarded, observer.types), + ignored: filter(ignored, observer.types), + unerrored: filter(unerrored, observer.types), + source, + sourceKey, + }) + } + } + + /** + * Imports an array of payloads from an external source (such as a backup file) + * and marks the items as dirty. + * @returns Resulting items + */ + public async importPayloads(payloads: DecryptedPayloadInterface[], historyMap: HistoryMap): Promise { + const sourcedPayloads = payloads.map((p) => p.copy(undefined, PayloadSource.FileImport)) + + const delta = new DeltaFileImport(this.getMasterCollection(), sourcedPayloads, historyMap) + + const emit = delta.result() + + await this.emitDeltaEmit(emit) + + return Uuids(payloads) + } + + public removePayloadLocally(payload: FullyFormedPayloadInterface) { + this.collection.discard(payload) + } + + public erroredPayloadsForContentType(contentType: ContentType): EncryptedPayloadInterface[] { + return this.collection.invalidElements().filter((p) => p.content_type === contentType) + } + + public async deleteErroredPayloads(payloads: EncryptedPayloadInterface[]): Promise { + const deleted = payloads.map( + (payload) => + new DeletedPayload( + { + ...payload.ejected(), + deleted: true, + content: undefined, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + }, + payload.source, + ), + ) + + await this.emitPayloads(deleted, PayloadEmitSource.LocalChanged) + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + payloads: { + integrityPayloads: this.integrityPayloads, + nonDeletedItemCount: this.nonDeletedItems.length, + invalidPayloadsCount: this.invalidPayloads.length, + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Payloads/Types.ts b/packages/snjs/lib/Services/Payloads/Types.ts new file mode 100644 index 000000000..27903e138 --- /dev/null +++ b/packages/snjs/lib/Services/Payloads/Types.ts @@ -0,0 +1,46 @@ +import { ContentType } from '@standardnotes/common' +import { + DecryptedPayloadInterface, + DeletedPayloadInterface, + DeltaEmit, + EncryptedPayloadInterface, + FullyFormedPayloadInterface, + PayloadEmitSource, +} from '@standardnotes/models' + +export type EmitQueue

= QueueElement

[] + +export type PayloadManagerChangeData = { + /** The payloads are pre-existing but have been changed */ + changed: FullyFormedPayloadInterface[] + + /** The payloads have been newly inserted */ + inserted: FullyFormedPayloadInterface[] + + /** The payloads have been deleted from local state (and remote state if applicable) */ + discarded: DeletedPayloadInterface[] + + /** Payloads for which encrypted overwrite protection is enabled and enacted */ + ignored: EncryptedPayloadInterface[] + + /** Payloads which were previously error decrypting but now successfully decrypted */ + unerrored: DecryptedPayloadInterface[] + + source: PayloadEmitSource + + sourceKey?: string +} + +export type PayloadsChangeObserverCallback = (data: PayloadManagerChangeData) => void + +export type PayloadsChangeObserver = { + types: ContentType[] + callback: PayloadsChangeObserverCallback + priority: number +} + +export type QueueElement

= { + emit: DeltaEmit + sourceKey?: string + resolve: (alteredPayloads: P[]) => void +} diff --git a/packages/snjs/lib/Services/Payloads/index.ts b/packages/snjs/lib/Services/Payloads/index.ts new file mode 100644 index 000000000..704cf30b5 --- /dev/null +++ b/packages/snjs/lib/Services/Payloads/index.ts @@ -0,0 +1,2 @@ +export * from './PayloadManager' +export * from './Types' diff --git a/packages/snjs/lib/Services/Preferences/PreferencesService.ts b/packages/snjs/lib/Services/Preferences/PreferencesService.ts new file mode 100644 index 000000000..247cec985 --- /dev/null +++ b/packages/snjs/lib/Services/Preferences/PreferencesService.ts @@ -0,0 +1,116 @@ +import { SNUserPrefs, PrefKey, PrefValue, UserPrefsMutator, ItemContent, FillItemContent } from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' +import { ItemManager } from '../Items/ItemManager' +import { SNSingletonManager } from '../Singleton/SingletonManager' +import { SNSyncService } from '../Sync/SyncService' +import { + AbstractService, + InternalEventBusInterface, + SyncEvent, + ApplicationStage, + PreferenceServiceInterface, + PreferencesServiceEvent, +} from '@standardnotes/services' + +export class SNPreferencesService + extends AbstractService + implements PreferenceServiceInterface +{ + private shouldReload = true + private reloading = false + private preferences?: SNUserPrefs + private removeItemObserver?: () => void + private removeSyncObserver?: () => void + + constructor( + private singletonManager: SNSingletonManager, + private itemManager: ItemManager, + private syncService: SNSyncService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + + this.removeItemObserver = itemManager.addObserver(ContentType.UserPrefs, () => { + this.shouldReload = true + }) + + this.removeSyncObserver = syncService.addEventObserver((event) => { + if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) { + void this.reload() + } + }) + } + + override deinit(): void { + this.removeItemObserver?.() + this.removeSyncObserver?.() + ;(this.singletonManager as unknown) = undefined + ;(this.itemManager as unknown) = undefined + + super.deinit() + } + + public override async handleApplicationStage(stage: ApplicationStage): Promise { + await super.handleApplicationStage(stage) + + if (stage === ApplicationStage.LoadedDatabase_12) { + /** Try to read preferences singleton from storage */ + this.preferences = this.singletonManager.findSingleton( + ContentType.UserPrefs, + SNUserPrefs.singletonPredicate, + ) + + if (this.preferences) { + void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged) + } + } + } + + getValue(key: K, defaultValue: PrefValue[K] | undefined): PrefValue[K] | undefined + getValue(key: K, defaultValue: PrefValue[K]): PrefValue[K] + getValue(key: K, defaultValue?: PrefValue[K]): PrefValue[K] | undefined { + return this.preferences?.getPref(key) ?? defaultValue + } + + async setValue(key: K, value: PrefValue[K]): Promise { + if (!this.preferences) { + return + } + + this.preferences = (await this.itemManager.changeItem(this.preferences, (m) => { + m.setPref(key, value) + })) as SNUserPrefs + + void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged) + + void this.syncService.sync() + } + + private async reload() { + if (!this.shouldReload || this.reloading) { + return + } + + this.reloading = true + + try { + const previousRef = this.preferences + + this.preferences = await this.singletonManager.findOrCreateContentTypeSingleton( + ContentType.UserPrefs, + FillItemContent({}), + ) + + if ( + previousRef?.uuid !== this.preferences.uuid || + this.preferences.userModifiedDate > previousRef.userModifiedDate + ) { + void this.notifyEvent(PreferencesServiceEvent.PreferencesChanged) + } + + this.shouldReload = false + } finally { + this.reloading = false + } + } +} diff --git a/packages/snjs/lib/Services/Protection/ClientInterface.ts b/packages/snjs/lib/Services/Protection/ClientInterface.ts new file mode 100644 index 000000000..4437de254 --- /dev/null +++ b/packages/snjs/lib/Services/Protection/ClientInterface.ts @@ -0,0 +1,8 @@ +import { ChallengeReason } from '@standardnotes/services' +import { DecryptedItem } from '@standardnotes/models' + +export interface ProtectionsClientInterface { + authorizeProtectedActionForItems(files: T[], challengeReason: ChallengeReason): Promise + + authorizeItemAccess(item: DecryptedItem): Promise +} diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts b/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts new file mode 100644 index 000000000..1093ccc20 --- /dev/null +++ b/packages/snjs/lib/Services/Protection/ProtectionService.spec.ts @@ -0,0 +1,110 @@ +import { ChallengeService } from '../Challenge' +import { EncryptionService } from '@standardnotes/encryption' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { SNProtectionService } from './ProtectionService' +import { InternalEventBus, InternalEventBusInterface, ChallengeReason } from '@standardnotes/services' +import { UuidGenerator } from '@standardnotes/utils' +import { + DecryptedPayload, + FileContent, + FileItem, + FillItemContent, + PayloadTimestampDefaults, +} from '@standardnotes/models' +import { ContentType } from '@standardnotes/common' + +const setupRandomUuid = () => { + UuidGenerator.SetGenerator(() => String(Math.random())) +} + +describe('protectionService', () => { + let protocolService: EncryptionService + let challengeService: ChallengeService + let storageService: DiskStorageService + let internalEventBus: InternalEventBusInterface + let protectionService: SNProtectionService + + const createService = () => { + return new SNProtectionService(protocolService, challengeService, storageService, internalEventBus) + } + + const createFile = (name: string, isProtected?: boolean) => { + return new FileItem( + new DecryptedPayload({ + uuid: String(Math.random()), + content_type: ContentType.File, + content: FillItemContent({ + name: name, + protected: isProtected, + }), + ...PayloadTimestampDefaults(), + }), + ) + } + + beforeEach(() => { + setupRandomUuid() + + internalEventBus = {} as jest.Mocked + + challengeService = {} as jest.Mocked + challengeService.promptForChallengeResponse = jest.fn() + + storageService = {} as jest.Mocked + storageService.getValue = jest.fn() + + protocolService = {} as jest.Mocked + protocolService.hasAccount = jest.fn().mockReturnValue(true) + protocolService.hasPasscode = jest.fn().mockReturnValue(false) + }) + + describe('files', () => { + it('unprotected file should not require auth', async () => { + protectionService = createService() + + const unprotectedFile = createFile('protected.txt', false) + + await protectionService.authorizeProtectedActionForItems([unprotectedFile], ChallengeReason.AccessProtectedFile) + + expect(challengeService.promptForChallengeResponse).not.toHaveBeenCalled() + }) + + it('protected file should require auth', async () => { + protectionService = createService() + + const protectedFile = createFile('protected.txt', true) + + await protectionService.authorizeProtectedActionForItems([protectedFile], ChallengeReason.AccessProtectedFile) + + expect(challengeService.promptForChallengeResponse).toHaveBeenCalled() + }) + + it('array of files having one protected should require auth', async () => { + protectionService = createService() + + const protectedFile = createFile('protected.txt', true) + const unprotectedFile = createFile('unprotected.txt', false) + + await protectionService.authorizeProtectedActionForItems( + [protectedFile, unprotectedFile], + ChallengeReason.AccessProtectedFile, + ) + + expect(challengeService.promptForChallengeResponse).toHaveBeenCalled() + }) + + it('array of files having none protected should not require auth', async () => { + protectionService = createService() + + const protectedFile = createFile('protected.txt', false) + const unprotectedFile = createFile('unprotected.txt', false) + + await protectionService.authorizeProtectedActionForItems( + [protectedFile, unprotectedFile], + ChallengeReason.AccessProtectedFile, + ) + + expect(challengeService.promptForChallengeResponse).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/snjs/lib/Services/Protection/ProtectionService.ts b/packages/snjs/lib/Services/Protection/ProtectionService.ts new file mode 100644 index 000000000..c7aef9f8f --- /dev/null +++ b/packages/snjs/lib/Services/Protection/ProtectionService.ts @@ -0,0 +1,339 @@ +import { Challenge } from './../Challenge/Challenge' +import { ChallengeService } from './../Challenge/ChallengeService' +import { SNLog } from '@Lib/Log' +import { DecryptedItem } from '@standardnotes/models' +import { EncryptionService } from '@standardnotes/encryption' +import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService' +import { isNullOrUndefined } from '@standardnotes/utils' +import { + AbstractService, + InternalEventBusInterface, + StorageValueModes, + ApplicationStage, + StorageKey, + DiagnosticInfo, + ChallengeReason, + ChallengePrompt, + ChallengeValidation, +} from '@standardnotes/services' +import { ProtectionsClientInterface } from './ClientInterface' +import { ContentType } from '@standardnotes/common' + +export enum ProtectionEvent { + UnprotectedSessionBegan = 'UnprotectedSessionBegan', + UnprotectedSessionExpired = 'UnprotectedSessionExpired', +} + +export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30 + +export enum UnprotectedAccessSecondsDuration { + OneMinute = 60, + FiveMinutes = 300, + OneHour = 3600, + OneWeek = 604800, +} + +export function isValidProtectionSessionLength(number: unknown): boolean { + return typeof number === 'number' && Object.values(UnprotectedAccessSecondsDuration).includes(number) +} + +export const ProtectionSessionDurations = [ + { + valueInSeconds: UnprotectedAccessSecondsDuration.OneMinute, + label: '1 Minute', + }, + { + valueInSeconds: UnprotectedAccessSecondsDuration.FiveMinutes, + label: '5 Minutes', + }, + { + valueInSeconds: UnprotectedAccessSecondsDuration.OneHour, + label: '1 Hour', + }, + { + valueInSeconds: UnprotectedAccessSecondsDuration.OneWeek, + label: '1 Week', + }, +] + +/** + * Enforces certain actions to require extra authentication, + * like viewing a protected note, as well as managing how long that + * authentication should be valid for. + */ +export class SNProtectionService extends AbstractService implements ProtectionsClientInterface { + private sessionExpiryTimeout = -1 + + constructor( + private protocolService: EncryptionService, + private challengeService: ChallengeService, + private storageService: DiskStorageService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public override deinit(): void { + clearTimeout(this.sessionExpiryTimeout) + ;(this.protocolService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.storageService as unknown) = undefined + super.deinit() + } + + override handleApplicationStage(stage: ApplicationStage): Promise { + if (stage === ApplicationStage.LoadedDatabase_12) { + this.updateSessionExpiryTimer(this.getSessionExpiryDate()) + } + return Promise.resolve() + } + + public hasProtectionSources(): boolean { + return this.protocolService.hasAccount() || this.protocolService.hasPasscode() || this.hasBiometricsEnabled() + } + + public hasUnprotectedAccessSession(): boolean { + if (!this.hasProtectionSources()) { + return true + } + return this.getSessionExpiryDate() > new Date() + } + + public hasBiometricsEnabled(): boolean { + const biometricsState = this.storageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped) + return Boolean(biometricsState) + } + + public enableBiometrics(): boolean { + if (this.hasBiometricsEnabled()) { + SNLog.onError(Error('Tried to enable biometrics when they already are enabled.')) + return false + } + + this.storageService.setValue(StorageKey.BiometricsState, true, StorageValueModes.Nonwrapped) + + return true + } + + public async disableBiometrics(): Promise { + if (!this.hasBiometricsEnabled()) { + SNLog.onError(Error('Tried to disable biometrics when they already are disabled.')) + return false + } + + if (await this.validateOrRenewSession(ChallengeReason.DisableBiometrics)) { + this.storageService.setValue(StorageKey.BiometricsState, false, StorageValueModes.Nonwrapped) + return true + } else { + return false + } + } + + public createLaunchChallenge(): Challenge | undefined { + const prompts: ChallengePrompt[] = [] + if (this.hasBiometricsEnabled()) { + prompts.push(new ChallengePrompt(ChallengeValidation.Biometric)) + } + if (this.protocolService.hasPasscode()) { + prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode)) + } + if (prompts.length > 0) { + return new Challenge(prompts, ChallengeReason.ApplicationUnlock, false) + } else { + return undefined + } + } + + async authorizeProtectedActionForItems( + items: T[], + challengeReason: ChallengeReason, + ): Promise { + let sessionValidation: Promise | undefined + const authorizedItems = [] + for (const item of items) { + const needsAuthorization = item.protected && !this.hasUnprotectedAccessSession() + if (needsAuthorization && !sessionValidation) { + sessionValidation = this.validateOrRenewSession(challengeReason) + } + if (!needsAuthorization || (await sessionValidation)) { + authorizedItems.push(item) + } + } + return authorizedItems + } + + async authorizeItemAccess(item: DecryptedItem): Promise { + if (!item.protected) { + return true + } + + return this.authorizeAction( + item.content_type === ContentType.Note + ? ChallengeReason.AccessProtectedNote + : ChallengeReason.AccessProtectedFile, + ) + } + + authorizeAddingPasscode(): Promise { + return this.authorizeAction(ChallengeReason.AddPasscode) + } + + authorizeChangingPasscode(): Promise { + return this.authorizeAction(ChallengeReason.ChangePasscode) + } + + authorizeRemovingPasscode(): Promise { + return this.authorizeAction(ChallengeReason.RemovePasscode) + } + + authorizeSearchingProtectedNotesText(): Promise { + return this.authorizeAction(ChallengeReason.SearchProtectedNotesText) + } + + authorizeFileImport(): Promise { + return this.authorizeAction(ChallengeReason.ImportFile) + } + + async authorizeBackupCreation(): Promise { + return this.authorizeAction(ChallengeReason.ExportBackup, { + fallBackToAccountPassword: true, + }) + } + + async authorizeMfaDisable(): Promise { + return this.authorizeAction(ChallengeReason.DisableMfa, { + requireAccountPassword: true, + }) + } + + async authorizeAutolockIntervalChange(): Promise { + return this.authorizeAction(ChallengeReason.ChangeAutolockInterval) + } + + async authorizeSessionRevoking(): Promise { + return this.authorizeAction(ChallengeReason.RevokeSession) + } + + async authorizeAction( + reason: ChallengeReason, + { fallBackToAccountPassword = true, requireAccountPassword = false } = {}, + ): Promise { + return this.validateOrRenewSession(reason, { + requireAccountPassword, + fallBackToAccountPassword, + }) + } + + private async validateOrRenewSession( + reason: ChallengeReason, + { fallBackToAccountPassword = true, requireAccountPassword = false } = {}, + ): Promise { + if (this.getSessionExpiryDate() > new Date()) { + return true + } + + const prompts: ChallengePrompt[] = [] + + if (this.hasBiometricsEnabled()) { + prompts.push(new ChallengePrompt(ChallengeValidation.Biometric)) + } + + if (this.protocolService.hasPasscode()) { + prompts.push(new ChallengePrompt(ChallengeValidation.LocalPasscode)) + } + + if (requireAccountPassword) { + if (!this.protocolService.hasAccount()) { + throw Error('Requiring account password for challenge with no account') + } + prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword)) + } + + if (prompts.length === 0) { + if (fallBackToAccountPassword && this.protocolService.hasAccount()) { + prompts.push(new ChallengePrompt(ChallengeValidation.AccountPassword)) + } else { + return true + } + } + const lastSessionLength = this.getLastSessionLength() + const chosenSessionLength = isValidProtectionSessionLength(lastSessionLength) + ? lastSessionLength + : UnprotectedAccessSecondsDuration.OneMinute + prompts.push( + new ChallengePrompt( + ChallengeValidation.ProtectionSessionDuration, + undefined, + undefined, + undefined, + undefined, + chosenSessionLength, + ), + ) + const response = await this.challengeService.promptForChallengeResponse(new Challenge(prompts, reason, true)) + if (response) { + const length = response.values.find( + (value) => value.prompt.validation === ChallengeValidation.ProtectionSessionDuration, + )?.value + if (isNullOrUndefined(length)) { + SNLog.error(Error('No valid protection session length found. Got ' + length)) + } else { + await this.setSessionLength(length as UnprotectedAccessSecondsDuration) + } + return true + } else { + return false + } + } + + public getSessionExpiryDate(): Date { + const expiresAt = this.storageService.getValue(StorageKey.ProtectionExpirey) + if (expiresAt) { + return new Date(expiresAt) + } else { + return new Date() + } + } + + public clearSession(): Promise { + void this.setSessionExpiryDate(new Date()) + return this.notifyEvent(ProtectionEvent.UnprotectedSessionExpired) + } + + private setSessionExpiryDate(date: Date) { + this.storageService.setValue(StorageKey.ProtectionExpirey, date) + } + + private getLastSessionLength(): UnprotectedAccessSecondsDuration | undefined { + return this.storageService.getValue(StorageKey.ProtectionSessionLength) + } + + private setSessionLength(length: UnprotectedAccessSecondsDuration): void { + this.storageService.setValue(StorageKey.ProtectionSessionLength, length) + const expiresAt = new Date() + expiresAt.setSeconds(expiresAt.getSeconds() + length) + this.setSessionExpiryDate(expiresAt) + this.updateSessionExpiryTimer(expiresAt) + void this.notifyEvent(ProtectionEvent.UnprotectedSessionBegan) + } + + private updateSessionExpiryTimer(expiryDate: Date) { + clearTimeout(this.sessionExpiryTimeout) + const timer: TimerHandler = () => { + void this.clearSession() + } + this.sessionExpiryTimeout = setTimeout(timer, expiryDate.getTime() - Date.now()) + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + protections: { + getSessionExpiryDate: this.getSessionExpiryDate(), + getLastSessionLength: this.getLastSessionLength(), + hasProtectionSources: this.hasProtectionSources(), + hasUnprotectedAccessSession: this.hasUnprotectedAccessSession(), + hasBiometricsEnabled: this.hasBiometricsEnabled(), + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Protection/index.ts b/packages/snjs/lib/Services/Protection/index.ts new file mode 100644 index 000000000..7e2ea69fc --- /dev/null +++ b/packages/snjs/lib/Services/Protection/index.ts @@ -0,0 +1,2 @@ +export * from './ClientInterface' +export * from './ProtectionService' diff --git a/packages/snjs/lib/Services/Session/SessionManager.ts b/packages/snjs/lib/Services/Session/SessionManager.ts new file mode 100644 index 000000000..056bfda0b --- /dev/null +++ b/packages/snjs/lib/Services/Session/SessionManager.ts @@ -0,0 +1,673 @@ +import { + AlertService, + AbstractService, + InternalEventBusInterface, + StorageKey, + DiagnosticInfo, + ChallengePrompt, + ChallengeValidation, + ChallengeKeyboardType, + ChallengeReason, + ChallengePromptTitle, +} from '@standardnotes/services' +import { Base64String } from '@standardnotes/sncrypto-common' +import { ClientDisplayableError } from '@standardnotes/responses' +import { CopyPayloadWithContentOverride } from '@standardnotes/models' +import { isNullOrUndefined } from '@standardnotes/utils' +import { JwtSession } from './Sessions/JwtSession' +import { + KeyParamsFromApiResponse, + SNRootKeyParams, + SNRootKey, + EncryptionService, + CreateNewRootKey, +} from '@standardnotes/encryption' +import { SessionStrings, SignInStrings } from '../Api/Messages' +import { RemoteSession, RawStorageValue } from './Sessions/Types' +import { Session } from './Sessions/Session' +import { SessionFromRawStorageValue } from './Sessions/Generator' +import { SessionsClientInterface } from './SessionsClientInterface' +import { ShareToken } from './ShareToken' +import { SNApiService } from '../Api/ApiService' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { SNWebSocketsService } from '../Api/WebsocketsService' +import { Strings } from '@Lib/Strings' +import { Subscription } from '@standardnotes/security' +import { TokenSession } from './Sessions/TokenSession' +import { UuidString } from '@Lib/Types/UuidString' +import * as Common from '@standardnotes/common' +import * as Messages from '../Api/Messages' +import * as Responses from '@standardnotes/responses' +import { Challenge, ChallengeService } from '../Challenge' +import { + ApiCallError, + ErrorMessage, + HttpErrorResponseBody, + UserApiServiceInterface, + UserRegistrationResponseBody, +} from '@standardnotes/api' + +export const MINIMUM_PASSWORD_LENGTH = 8 +export const MissingAccountParams = 'missing-params' + +type SessionManagerResponse = { + response: Responses.HttpResponse + rootKey?: SNRootKey + keyParams?: Common.AnyKeyParamsContent +} + +const cleanedEmailString = (email: string) => { + return email.trim().toLowerCase() +} + +export enum SessionEvent { + Restored = 'SessionRestored', + Revoked = 'SessionRevoked', +} + +/** + * The session manager is responsible for loading initial user state, and any relevant + * server credentials, such as the session token. It also exposes methods for registering + * for a new account, signing into an existing one, or changing an account password. + */ +export class SNSessionManager extends AbstractService implements SessionsClientInterface { + private user?: Responses.User + private isSessionRenewChallengePresented = false + + constructor( + private diskStorageService: DiskStorageService, + private apiService: SNApiService, + private userApiService: UserApiServiceInterface, + private alertService: AlertService, + private protocolService: EncryptionService, + private challengeService: ChallengeService, + private webSocketsService: SNWebSocketsService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + apiService.setInvalidSessionObserver((revoked) => { + if (revoked) { + void this.notifyEvent(SessionEvent.Revoked) + } else { + void this.reauthenticateInvalidSession() + } + }) + } + + override deinit(): void { + ;(this.protocolService as unknown) = undefined + ;(this.diskStorageService as unknown) = undefined + ;(this.apiService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.webSocketsService as unknown) = undefined + this.user = undefined + super.deinit() + } + + private setUser(user?: Responses.User) { + this.user = user + this.apiService.setUser(user) + } + + public initializeFromDisk() { + this.setUser(this.diskStorageService.getValue(StorageKey.User)) + + if (!this.user) { + const legacyUuidLookup = this.diskStorageService.getValue(StorageKey.LegacyUuid) + if (legacyUuidLookup) { + this.setUser({ uuid: legacyUuidLookup, email: legacyUuidLookup }) + } + } + + const rawSession = this.diskStorageService.getValue(StorageKey.Session) + if (rawSession) { + const session = SessionFromRawStorageValue(rawSession) + this.setSession(session, false) + this.webSocketsService.startWebSocketConnection(session.authorizationValue) + } + } + + private setSession(session: Session, persist = true): void { + this.apiService.setSession(session, persist) + } + + public online() { + return !this.offline() + } + + public offline() { + return isNullOrUndefined(this.apiService.getSession()) + } + + public getUser() { + return this.user + } + + public getSureUser() { + return this.user as Responses.User + } + + public getSession() { + return this.apiService.getSession() + } + + public async signOut() { + this.setUser(undefined) + const session = this.apiService.getSession() + if (session && session.canExpire()) { + await this.apiService.signOut() + this.webSocketsService.closeWebSocketConnection() + } + } + + public isSignedIn(): boolean { + return this.getUser() != undefined + } + + public isSignedIntoFirstPartyServer(): boolean { + return this.isSignedIn() && !this.apiService.isThirdPartyHostUsed() + } + + public async reauthenticateInvalidSession( + cancelable = true, + onResponse?: (response: Responses.HttpResponse) => void, + ): Promise { + if (this.isSessionRenewChallengePresented) { + return + } + this.isSessionRenewChallengePresented = true + const challenge = new Challenge( + [ + new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.EmailInputPlaceholder, false), + new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder), + ], + ChallengeReason.Custom, + cancelable, + SessionStrings.EnterEmailAndPassword, + SessionStrings.RecoverSession(this.getUser()?.email), + ) + return new Promise((resolve) => { + this.challengeService.addChallengeObserver(challenge, { + onCancel: () => { + this.isSessionRenewChallengePresented = false + }, + onComplete: () => { + this.isSessionRenewChallengePresented = false + }, + onNonvalidatedSubmit: async (challengeResponse) => { + const email = challengeResponse.values[0].value as string + const password = challengeResponse.values[1].value as string + const currentKeyParams = this.protocolService.getAccountKeyParams() + const signInResult = await this.signIn( + email, + password, + false, + this.diskStorageService.isEphemeralSession(), + currentKeyParams?.version, + ) + if (signInResult.response.error) { + this.challengeService.setValidationStatusForChallenge(challenge, challengeResponse!.values[1], false) + onResponse?.(signInResult.response) + } else { + resolve() + this.challengeService.completeChallenge(challenge) + void this.notifyEvent(SessionEvent.Restored) + void this.alertService.alert(SessionStrings.SessionRestored) + } + }, + }) + void this.challengeService.promptForChallengeResponse(challenge) + }) + } + + public async getSubscription(): Promise { + const result = await this.apiService.getSubscription(this.getSureUser().uuid) + + if (result.error) { + return ClientDisplayableError.FromError(result.error) + } + + const subscription = (result as Responses.GetSubscriptionResponse).data!.subscription! + + return subscription + } + + public async getAvailableSubscriptions(): Promise { + const response = await this.apiService.getAvailableSubscriptions() + + if (response.error) { + return ClientDisplayableError.FromError(response.error) + } + + return (response as Responses.GetAvailableSubscriptionsResponse).data! + } + + private async promptForMfaValue(): Promise { + const challenge = new Challenge( + [ + new ChallengePrompt( + ChallengeValidation.None, + ChallengePromptTitle.Mfa, + SessionStrings.MfaInputPlaceholder, + false, + ChallengeKeyboardType.Numeric, + ), + ], + ChallengeReason.Custom, + true, + SessionStrings.EnterMfa, + ) + + const response = await this.challengeService.promptForChallengeResponse(challenge) + + if (response) { + this.challengeService.completeChallenge(challenge) + return response.values[0].value as string + } + + return undefined + } + + async register(email: string, password: string, ephemeral: boolean): Promise { + if (password.length < MINIMUM_PASSWORD_LENGTH) { + throw new ApiCallError( + ErrorMessage.InsufficientPasswordMessage.replace('%LENGTH%', MINIMUM_PASSWORD_LENGTH.toString()), + ) + } + + const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable() + if (canceled) { + throw new ApiCallError(ErrorMessage.PasscodeRequired) + } + + email = cleanedEmailString(email) + + const rootKey = await this.protocolService.createRootKey(email, password, Common.KeyParamsOrigination.Registration) + const serverPassword = rootKey.serverPassword as string + const keyParams = rootKey.keyParams + + const registerResponse = await this.userApiService.register(email, serverPassword, keyParams, ephemeral) + + if ('error' in registerResponse.data) { + throw new ApiCallError((registerResponse.data as HttpErrorResponseBody).error.message) + } + + await this.handleAuthResponse(registerResponse.data, rootKey, wrappingKey) + + return registerResponse.data + } + + private async retrieveKeyParams( + email: string, + mfaKeyPath?: string, + mfaCode?: string, + ): Promise<{ + keyParams?: SNRootKeyParams + response: Responses.KeyParamsResponse | Responses.HttpResponse + mfaKeyPath?: string + mfaCode?: string + }> { + const response = await this.apiService.getAccountKeyParams({ + email, + mfaKeyPath, + mfaCode, + }) + + if (response.error || isNullOrUndefined(response.data)) { + if (mfaCode) { + await this.alertService.alert(SignInStrings.IncorrectMfa) + } + if (response.error?.payload?.mfa_key) { + /** Prompt for MFA code and try again */ + const inputtedCode = await this.promptForMfaValue() + if (!inputtedCode) { + /** User dismissed window without input */ + return { + response: this.apiService.createErrorResponse( + SignInStrings.SignInCanceledMissingMfa, + Responses.StatusCode.CanceledMfa, + ), + } + } + return this.retrieveKeyParams(email, response.error.payload.mfa_key, inputtedCode) + } else { + return { response } + } + } + /** Make sure to use client value for identifier/email */ + const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, email) + if (!keyParams || !keyParams.version) { + return { + response: this.apiService.createErrorResponse(Messages.API_MESSAGE_FALLBACK_LOGIN_FAIL), + } + } + return { keyParams, response, mfaKeyPath, mfaCode } + } + + public async signIn( + email: string, + password: string, + strict = false, + ephemeral = false, + minAllowedVersion?: Common.ProtocolVersion, + ): Promise { + const result = await this.performSignIn(email, password, strict, ephemeral, minAllowedVersion) + if ( + result.response.error && + result.response.error.status !== Responses.StatusCode.LocalValidationError && + result.response.error.status !== Responses.StatusCode.CanceledMfa + ) { + const cleanedEmail = cleanedEmailString(email) + if (cleanedEmail !== email) { + /** + * Try signing in with trimmed + lowercase version of email + */ + return this.performSignIn(cleanedEmail, password, strict, ephemeral, minAllowedVersion) + } else { + return result + } + } else { + return result + } + } + + private async performSignIn( + email: string, + password: string, + strict = false, + ephemeral = false, + minAllowedVersion?: Common.ProtocolVersion, + ): Promise { + const paramsResult = await this.retrieveKeyParams(email) + if (paramsResult.response.error) { + return { + response: paramsResult.response, + } + } + const keyParams = paramsResult.keyParams! + if (!this.protocolService.supportedVersions().includes(keyParams.version)) { + if (this.protocolService.isVersionNewerThanLibraryVersion(keyParams.version)) { + return { + response: this.apiService.createErrorResponse(Messages.UNSUPPORTED_PROTOCOL_VERSION), + } + } else { + return { + response: this.apiService.createErrorResponse(Messages.EXPIRED_PROTOCOL_VERSION), + } + } + } + + if (Common.isProtocolVersionExpired(keyParams.version)) { + /* Cost minimums only apply to now outdated versions (001 and 002) */ + const minimum = this.protocolService.costMinimumForVersion(keyParams.version) + if (keyParams.content002.pw_cost < minimum) { + return { + response: this.apiService.createErrorResponse(Messages.INVALID_PASSWORD_COST), + } + } + + const expiredMessages = Strings.Confirm.ProtocolVersionExpired(keyParams.version) + const confirmed = await this.alertService.confirm( + expiredMessages.Message, + expiredMessages.Title, + expiredMessages.ConfirmButton, + ) + + if (!confirmed) { + return { + response: this.apiService.createErrorResponse(Messages.API_MESSAGE_FALLBACK_LOGIN_FAIL), + } + } + } + + if (!this.protocolService.platformSupportsKeyDerivation(keyParams)) { + return { + response: this.apiService.createErrorResponse(Messages.UNSUPPORTED_KEY_DERIVATION), + } + } + + if (strict) { + minAllowedVersion = this.protocolService.getLatestVersion() + } + + if (!isNullOrUndefined(minAllowedVersion)) { + if (!Common.leftVersionGreaterThanOrEqualToRight(keyParams.version, minAllowedVersion)) { + return { + response: this.apiService.createErrorResponse( + Messages.StrictSignInFailed(keyParams.version, minAllowedVersion), + ), + } + } + } + const rootKey = await this.protocolService.computeRootKey(password, keyParams) + const signInResponse = await this.bypassChecksAndSignInWithRootKey(email, rootKey, ephemeral) + return { + response: signInResponse, + } + } + + public async bypassChecksAndSignInWithRootKey( + email: string, + rootKey: SNRootKey, + ephemeral = false, + ): Promise { + const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable() + + if (canceled) { + return this.apiService.createErrorResponse( + SignInStrings.PasscodeRequired, + Responses.StatusCode.LocalValidationError, + ) + } + + const signInResponse = await this.apiService.signIn({ + email, + serverPassword: rootKey.serverPassword!, + ephemeral, + }) + + if (signInResponse.error || !signInResponse.data) { + return signInResponse + } + + const updatedKeyParams = (signInResponse as Responses.SignInResponse).data.key_params + const expandedRootKey = new SNRootKey( + CopyPayloadWithContentOverride(rootKey.payload, { + keyParams: updatedKeyParams || rootKey.keyParams.getPortableValue(), + }), + ) + + await this.handleSuccessAuthResponse(signInResponse as Responses.SignInResponse, expandedRootKey, wrappingKey) + + return signInResponse + } + + public async changeCredentials(parameters: { + currentServerPassword: string + newRootKey: SNRootKey + wrappingKey?: SNRootKey + newEmail?: string + }): Promise { + const userUuid = this.user!.uuid + const response = await this.apiService.changeCredentials({ + userUuid, + currentServerPassword: parameters.currentServerPassword, + newServerPassword: parameters.newRootKey.serverPassword!, + newKeyParams: parameters.newRootKey.keyParams, + newEmail: parameters.newEmail, + }) + + return this.processChangeCredentialsResponse( + response as Responses.ChangeCredentialsResponse, + parameters.newRootKey, + parameters.wrappingKey, + ) + } + + public async getSessionsList(): Promise< + (Responses.HttpResponse & { data: RemoteSession[] }) | Responses.HttpResponse + > { + const response = await this.apiService.getSessionsList() + if (response.error || isNullOrUndefined(response.data)) { + return response + } + ;( + response as Responses.HttpResponse & { + data: RemoteSession[] + } + ).data = (response as Responses.SessionListResponse).data + .map((session) => ({ + ...session, + updated_at: new Date(session.updated_at), + })) + .sort((s1: RemoteSession, s2: RemoteSession) => (s1.updated_at < s2.updated_at ? 1 : -1)) + return response + } + + public async revokeSession(sessionId: UuidString): Promise { + const response = await this.apiService.deleteSession(sessionId) + return response + } + + public async revokeAllOtherSessions(): Promise { + const response = await this.getSessionsList() + if (response.error != undefined || response.data == undefined) { + throw new Error(response.error?.message ?? Messages.API_MESSAGE_GENERIC_SYNC_FAIL) + } + const sessions = response.data as RemoteSession[] + const otherSessions = sessions.filter((session) => !session.current) + await Promise.all(otherSessions.map((session) => this.revokeSession(session.uuid))) + } + + private async processChangeCredentialsResponse( + response: Responses.ChangeCredentialsResponse, + newRootKey: SNRootKey, + wrappingKey?: SNRootKey, + ): Promise { + if (!response.error && response.data) { + await this.handleSuccessAuthResponse(response as Responses.ChangeCredentialsResponse, newRootKey, wrappingKey) + } + return { + response: response, + keyParams: (response as Responses.ChangeCredentialsResponse).data?.key_params, + } + } + + public async createDemoShareToken(): Promise { + const session = this.getSession() + if (!session) { + return new ClientDisplayableError('Cannot generate share token without active session') + } + if (!(session instanceof TokenSession)) { + return new ClientDisplayableError('Cannot generate share token with non-token session') + } + + const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams + + const payload: ShareToken = { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + accessExpiration: session.accessExpiration, + refreshExpiration: session.refreshExpiration, + readonlyAccess: true, + masterKey: this.protocolService.getRootKey()?.masterKey as string, + keyParams: keyParams.content, + user: this.getSureUser(), + host: this.apiService.getHost(), + } + + return this.protocolService.crypto.base64Encode(JSON.stringify(payload)) + } + + private decodeDemoShareToken(token: Base64String): ShareToken { + const jsonString = this.protocolService.crypto.base64Decode(token) + return JSON.parse(jsonString) + } + + public async populateSessionFromDemoShareToken(token: Base64String): Promise { + const sharePayload = this.decodeDemoShareToken(token) + + const rootKey = CreateNewRootKey({ + masterKey: sharePayload.masterKey, + keyParams: sharePayload.keyParams, + version: sharePayload.keyParams.version, + }) + + const user = sharePayload.user + + const session = new TokenSession( + sharePayload.accessToken, + sharePayload.accessExpiration, + sharePayload.refreshToken, + sharePayload.refreshExpiration, + sharePayload.readonlyAccess, + ) + + await this.populateSession(rootKey, user, session, sharePayload.host) + } + + private async populateSession( + rootKey: SNRootKey, + user: Responses.User, + session: Session, + host: string, + wrappingKey?: SNRootKey, + ) { + await this.protocolService.setRootKey(rootKey, wrappingKey) + + this.setUser(user) + + this.diskStorageService.setValue(StorageKey.User, user) + + void this.apiService.setHost(host) + + await this.setSession(session) + + this.webSocketsService.startWebSocketConnection(session.authorizationValue) + } + + private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) { + const session = new TokenSession( + body.session.access_token, + body.session.access_expiration, + body.session.refresh_token, + body.session.refresh_expiration, + body.session.readonly_access, + ) + await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey) + } + + /** + * @deprecated use handleAuthResponse instead + */ + private async handleSuccessAuthResponse( + response: Responses.SignInResponse | Responses.ChangeCredentialsResponse, + rootKey: SNRootKey, + wrappingKey?: SNRootKey, + ) { + const { data } = response + const user = data.user as Responses.User + + const isLegacyJwtResponse = data.token != undefined + if (isLegacyJwtResponse) { + const session = new JwtSession(data.token as string) + await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) + } else if (data.session) { + const session = TokenSession.FromApiResponse(response) + await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) + } + } + + override getDiagnostics(): Promise { + return Promise.resolve({ + session: { + isSessionRenewChallengePresented: this.isSessionRenewChallengePresented, + online: this.online(), + offline: this.offline(), + isSignedIn: this.isSignedIn(), + isSignedIntoFirstPartyServer: this.isSignedIntoFirstPartyServer(), + }, + }) + } +} diff --git a/packages/snjs/lib/Services/Session/Sessions/Generator.ts b/packages/snjs/lib/Services/Session/Sessions/Generator.ts new file mode 100644 index 000000000..e232808e5 --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/Generator.ts @@ -0,0 +1,18 @@ +import { JwtSession } from './JwtSession' +import { TokenSession } from './TokenSession' +import { RawSessionPayload, RawStorageValue } from './Types' + +export function SessionFromRawStorageValue(raw: RawStorageValue): JwtSession | TokenSession { + if ('jwt' in raw) { + return new JwtSession(raw.jwt as string) + } else { + const rawSession = raw as RawSessionPayload + return new TokenSession( + rawSession.accessToken, + rawSession.accessExpiration, + rawSession.refreshToken, + rawSession.refreshExpiration, + rawSession.readonlyAccess, + ) + } +} diff --git a/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts b/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts new file mode 100644 index 000000000..f881e4477 --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/JwtSession.ts @@ -0,0 +1,20 @@ +import { Session } from './Session' + +/** Legacy, for protocol versions <= 003 */ + +export class JwtSession extends Session { + public jwt: string + + constructor(jwt: string) { + super() + this.jwt = jwt + } + + public get authorizationValue(): string { + return this.jwt + } + + public canExpire(): false { + return false + } +} diff --git a/packages/snjs/lib/Services/Session/Sessions/Session.ts b/packages/snjs/lib/Services/Session/Sessions/Session.ts new file mode 100644 index 000000000..23a8f7d29 --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/Session.ts @@ -0,0 +1,6 @@ +export abstract class Session { + public abstract canExpire(): boolean + + /** Return the token that should be included in the header of authorized network requests */ + public abstract get authorizationValue(): string +} diff --git a/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts b/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts new file mode 100644 index 000000000..c53a34a56 --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/TokenSession.ts @@ -0,0 +1,46 @@ +import { SessionBody, SessionRenewalResponse } from '@standardnotes/responses' +import { Session } from './Session' + +/** For protocol versions >= 004 */ +export class TokenSession extends Session { + static FromApiResponse(response: SessionRenewalResponse) { + const body = response.data.session as SessionBody + const accessToken: string = body.access_token + const refreshToken: string = body.refresh_token + const accessExpiration: number = body.access_expiration + const refreshExpiration: number = body.refresh_expiration + const readonlyAccess: boolean = body.readonly_access + + return new TokenSession(accessToken, accessExpiration, refreshToken, refreshExpiration, readonlyAccess) + } + + constructor( + public accessToken: string, + public accessExpiration: number, + public refreshToken: string, + public refreshExpiration: number, + private readonlyAccess: boolean, + ) { + super() + } + + isReadOnly() { + return this.readonlyAccess + } + + private getExpireAt() { + return this.accessExpiration || 0 + } + + public get authorizationValue() { + return this.accessToken + } + + public canExpire() { + return true + } + + public isExpired() { + return this.getExpireAt() < Date.now() + } +} diff --git a/packages/snjs/lib/Services/Session/Sessions/Types.ts b/packages/snjs/lib/Services/Session/Sessions/Types.ts new file mode 100644 index 000000000..ddcb4f447 --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/Types.ts @@ -0,0 +1,22 @@ +import { Uuid } from '@standardnotes/common' + +export type RawJwtPayload = { + jwt?: string +} + +export type RawSessionPayload = { + accessToken: string + refreshToken: string + accessExpiration: number + refreshExpiration: number + readonlyAccess: boolean +} + +export type RawStorageValue = RawJwtPayload | RawSessionPayload + +export type RemoteSession = { + uuid: Uuid + updated_at: Date + device_info: string + current: boolean +} diff --git a/packages/snjs/lib/Services/Session/Sessions/index.ts b/packages/snjs/lib/Services/Session/Sessions/index.ts new file mode 100644 index 000000000..d80a6ea0a --- /dev/null +++ b/packages/snjs/lib/Services/Session/Sessions/index.ts @@ -0,0 +1,5 @@ +export * from './Generator' +export * from './JwtSession' +export * from './Session' +export * from './TokenSession' +export * from './Types' diff --git a/packages/snjs/lib/Services/Session/SessionsClientInterface.ts b/packages/snjs/lib/Services/Session/SessionsClientInterface.ts new file mode 100644 index 000000000..78ab38a81 --- /dev/null +++ b/packages/snjs/lib/Services/Session/SessionsClientInterface.ts @@ -0,0 +1,8 @@ +import { ClientDisplayableError } from '@standardnotes/responses' +import { Base64String } from '@standardnotes/sncrypto-common' + +export interface SessionsClientInterface { + createDemoShareToken(): Promise + + populateSessionFromDemoShareToken(token: Base64String): Promise +} diff --git a/packages/snjs/lib/Services/Session/ShareToken.ts b/packages/snjs/lib/Services/Session/ShareToken.ts new file mode 100644 index 000000000..16a4932e6 --- /dev/null +++ b/packages/snjs/lib/Services/Session/ShareToken.ts @@ -0,0 +1,10 @@ +import { User } from '@standardnotes/responses' +import { AnyKeyParamsContent } from '@standardnotes/common' +import { RawSessionPayload } from './Sessions/Types' + +export type ShareToken = RawSessionPayload & { + masterKey: string + keyParams: AnyKeyParamsContent + user: User + host: string +} diff --git a/packages/snjs/lib/Services/Session/index.ts b/packages/snjs/lib/Services/Session/index.ts new file mode 100644 index 000000000..b47d62dd7 --- /dev/null +++ b/packages/snjs/lib/Services/Session/index.ts @@ -0,0 +1,4 @@ +export * from './SessionManager' +export * from './Sessions' +export * from './SessionsClientInterface' +export * from './ShareToken' diff --git a/packages/snjs/lib/Services/Settings/SNSettingsService.ts b/packages/snjs/lib/Services/Settings/SNSettingsService.ts new file mode 100644 index 000000000..cc4621be7 --- /dev/null +++ b/packages/snjs/lib/Services/Settings/SNSettingsService.ts @@ -0,0 +1,81 @@ +import { SNApiService } from '../Api/ApiService' +import { SettingsGateway } from './SettingsGateway' +import { SNSessionManager } from '../Session/SessionManager' +import { + CloudProvider, + EmailBackupFrequency, + SettingName, + SensitiveSettingName, + SubscriptionSettingName, +} from '@standardnotes/settings' +import { ExtensionsServerURL } from '@Lib/Hosts' +import { AbstractService, InternalEventBusInterface } from '@standardnotes/services' +import { SettingsClientInterface } from './SettingsClientInterface' + +export class SNSettingsService extends AbstractService implements SettingsClientInterface { + private provider!: SettingsGateway + private frequencyOptionsLabels = { + [EmailBackupFrequency.Disabled]: 'No email backups', + [EmailBackupFrequency.Daily]: 'Daily', + [EmailBackupFrequency.Weekly]: 'Weekly', + } + + private cloudProviderIntegrationUrlEndpoints = { + [CloudProvider.Dropbox]: 'dropbox', + [CloudProvider.Google]: 'gdrive', + [CloudProvider.OneDrive]: 'onedrive', + } + + constructor( + private readonly sessionManager: SNSessionManager, + private readonly apiService: SNApiService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + initializeFromDisk(): void { + this.provider = new SettingsGateway(this.apiService, this.sessionManager) + } + + async listSettings() { + return this.provider.listSettings() + } + + async getSetting(name: SettingName) { + return this.provider.getSetting(name) + } + + async getSubscriptionSetting(name: SubscriptionSettingName) { + return this.provider.getSubscriptionSetting(name) + } + + async updateSetting(name: SettingName, payload: string, sensitive = false) { + return this.provider.updateSetting(name, payload, sensitive) + } + + async getDoesSensitiveSettingExist(name: SensitiveSettingName) { + return this.provider.getDoesSensitiveSettingExist(name) + } + + async deleteSetting(name: SettingName) { + return this.provider.deleteSetting(name) + } + + getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string { + return this.frequencyOptionsLabels[frequency] + } + + getCloudProviderIntegrationUrl(cloudProviderName: CloudProvider, isDevEnvironment: boolean): string { + const { Dev, Prod } = ExtensionsServerURL + const extServerUrl = isDevEnvironment ? Dev : Prod + return `${extServerUrl}/${this.cloudProviderIntegrationUrlEndpoints[cloudProviderName]}?redirect_url=${extServerUrl}/components/cloudlink?` + } + + override deinit(): void { + this.provider?.deinit() + ;(this.provider as unknown) = undefined + ;(this.sessionManager as unknown) = undefined + ;(this.apiService as unknown) = undefined + } +} diff --git a/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts b/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts new file mode 100644 index 000000000..58e710a0a --- /dev/null +++ b/packages/snjs/lib/Services/Settings/SettingsClientInterface.ts @@ -0,0 +1,16 @@ +import { SettingName, SensitiveSettingName, EmailBackupFrequency } from '@standardnotes/settings' +import { SettingsList } from './SettingsList' + +export interface SettingsClientInterface { + listSettings(): Promise + + getSetting(name: SettingName): Promise + + getDoesSensitiveSettingExist(name: SensitiveSettingName): Promise + + updateSetting(name: SettingName, payload: string, sensitive?: boolean): Promise + + deleteSetting(name: SettingName): Promise + + getEmailBackupFrequencyOptionLabel(frequency: EmailBackupFrequency): string +} diff --git a/packages/snjs/lib/Services/Settings/SettingsGateway.ts b/packages/snjs/lib/Services/Settings/SettingsGateway.ts new file mode 100644 index 000000000..fea42b26e --- /dev/null +++ b/packages/snjs/lib/Services/Settings/SettingsGateway.ts @@ -0,0 +1,110 @@ +import { SettingsList } from './SettingsList' +import { SettingName, SensitiveSettingName, SubscriptionSettingName } from '@standardnotes/settings' +import * as messages from '../Api/Messages' +import { StatusCode, User } from '@standardnotes/responses' +import { SettingsServerInterface } from './SettingsServerInterface' + +/** + * SettingsGateway coordinates communication with the API service + * wrapping the userUuid provision for simpler consumption + */ +export class SettingsGateway { + constructor( + private readonly settingsApi: SettingsServerInterface, + private readonly userProvider: { getUser: () => User | undefined }, + ) {} + + isReadyForModification(): boolean { + return this.getUser() != undefined + } + + private getUser() { + return this.userProvider.getUser() + } + + private get userUuid() { + const user = this.getUser() + if (user == undefined || user.uuid == undefined) { + throw new Error(messages.API_MESSAGE_INVALID_SESSION) + } + return user.uuid + } + + async listSettings() { + const { error, data } = await this.settingsApi.listSettings(this.userUuid) + + if (error != undefined) { + throw new Error(error.message) + } + + if (data == undefined || data.settings == undefined) { + return new SettingsList([]) + } + + const settings: SettingsList = new SettingsList(data.settings) + return settings + } + + async getSetting(name: SettingName): Promise { + const response = await this.settingsApi.getSetting(this.userUuid, name) + + // Backend responds with 400 when setting doesn't exist + if (response.status === StatusCode.HttpBadRequest) { + return undefined + } + + if (response.error != undefined) { + throw new Error(response.error.message) + } + + return response?.data?.setting?.value ?? undefined + } + + async getSubscriptionSetting(name: SubscriptionSettingName): Promise { + const response = await this.settingsApi.getSubscriptionSetting(this.userUuid, name) + + if (response.status === StatusCode.HttpBadRequest) { + return undefined + } + + if (response.error != undefined) { + throw new Error(response.error.message) + } + + return response?.data?.setting?.value ?? undefined + } + + async getDoesSensitiveSettingExist(name: SensitiveSettingName): Promise { + const response = await this.settingsApi.getSetting(this.userUuid, name) + + // Backend responds with 400 when setting doesn't exist + if (response.status === StatusCode.HttpBadRequest) { + return false + } + + if (response.error != undefined) { + throw new Error(response.error.message) + } + + return response.data?.success ?? false + } + + async updateSetting(name: SettingName, payload: string, sensitive: boolean): Promise { + const { error } = await this.settingsApi.updateSetting(this.userUuid, name, payload, sensitive) + if (error != undefined) { + throw new Error(error.message) + } + } + + async deleteSetting(name: SettingName): Promise { + const { error } = await this.settingsApi.deleteSetting(this.userUuid, name) + if (error != undefined) { + throw new Error(error.message) + } + } + + deinit() { + ;(this.settingsApi as unknown) = undefined + ;(this.userProvider as unknown) = undefined + } +} diff --git a/packages/snjs/lib/Services/Settings/SettingsList.ts b/packages/snjs/lib/Services/Settings/SettingsList.ts new file mode 100644 index 000000000..3636a8b60 --- /dev/null +++ b/packages/snjs/lib/Services/Settings/SettingsList.ts @@ -0,0 +1,41 @@ +import { SettingData } from '@standardnotes/responses' +import { + OneDriveBackupFrequency, + MuteSignInEmailsOption, + MuteFailedCloudBackupsEmailsOption, + MuteFailedBackupsEmailsOption, + CloudProvider, + DropboxBackupFrequency, + EmailBackupFrequency, + GoogleDriveBackupFrequency, + ListedAuthorSecretsData, + LogSessionUserAgentOption, + SettingName, +} from '@standardnotes/settings' + +type SettingType = + | CloudProvider + | DropboxBackupFrequency + | EmailBackupFrequency + | GoogleDriveBackupFrequency + | ListedAuthorSecretsData + | LogSessionUserAgentOption + | MuteFailedBackupsEmailsOption + | MuteFailedCloudBackupsEmailsOption + | MuteSignInEmailsOption + | OneDriveBackupFrequency + +export class SettingsList { + private map: Partial> = {} + + constructor(settings: SettingData[]) { + for (const setting of settings) { + this.map[setting.name as SettingName] = setting + } + } + + getSettingValue(setting: SettingName, defaultValue: D): T { + const settingData = this.map[setting] + return (settingData?.value as unknown as T) || (defaultValue as unknown as T) + } +} diff --git a/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts b/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts new file mode 100644 index 000000000..3056e1821 --- /dev/null +++ b/packages/snjs/lib/Services/Settings/SettingsServerInterface.ts @@ -0,0 +1,24 @@ +import { + DeleteSettingResponse, + GetSettingResponse, + ListSettingsResponse, + UpdateSettingResponse, +} from '@standardnotes/responses' +import { UuidString } from '@Lib/Types/UuidString' + +export interface SettingsServerInterface { + listSettings(userUuid: UuidString): Promise + + updateSetting( + userUuid: UuidString, + settingName: string, + settingValue: string, + sensitive: boolean, + ): Promise + + getSetting(userUuid: UuidString, settingName: string): Promise + + getSubscriptionSetting(userUuid: UuidString, settingName: string): Promise + + deleteSetting(userUuid: UuidString, settingName: string): Promise +} diff --git a/packages/snjs/lib/Services/Settings/index.ts b/packages/snjs/lib/Services/Settings/index.ts new file mode 100644 index 000000000..6164a7d86 --- /dev/null +++ b/packages/snjs/lib/Services/Settings/index.ts @@ -0,0 +1 @@ +export * from './SNSettingsService' diff --git a/packages/snjs/lib/Services/Singleton/SingletonManager.ts b/packages/snjs/lib/Services/Singleton/SingletonManager.ts new file mode 100644 index 000000000..4cf8f0702 --- /dev/null +++ b/packages/snjs/lib/Services/Singleton/SingletonManager.ts @@ -0,0 +1,231 @@ +import { PayloadManager } from './../Payloads/PayloadManager' +import { ContentType } from '@standardnotes/common' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { + DecryptedItemInterface, + DecryptedPayload, + SingletonStrategy, + ItemContent, + PredicateInterface, + PayloadEmitSource, + PayloadTimestampDefaults, + getIncrementedDirtyIndex, +} from '@standardnotes/models' +import { arrayByRemovingFromIndex, extendArray, UuidGenerator } from '@standardnotes/utils' +import { SNSyncService } from '../Sync/SyncService' +import { AbstractService, InternalEventBusInterface, SyncEvent } from '@standardnotes/services' + +/** + * The singleton manager allow consumers to ensure that only 1 item exists of a certain + * predicate. For example, consumers may want to ensure that only one item of contentType + * UserPreferences exist. The singleton manager allows consumers to do this via 2 methods: + * 1. Consumers may use `findOrCreateContentTypeSingleton` to retrieve an item if it exists, or create + * it otherwise. While this method may serve most cases, it does not allow the consumer + * to subscribe to changes, such as if after this method is called, a UserPreferences object + * is downloaded from a remote source. + * 2. Items can override isSingleton, singletonPredicate, and strategyWhenConflictingWithItem (optional) + * to automatically gain singleton resolution. + */ +export class SNSingletonManager extends AbstractService { + private resolveQueue: DecryptedItemInterface[] = [] + + private removeItemObserver!: () => void + private removeSyncObserver!: () => void + + constructor( + private itemManager: ItemManager, + private payloadManager: PayloadManager, + private syncService: SNSyncService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + this.addObservers() + } + + public override deinit(): void { + ;(this.syncService as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + + this.resolveQueue.length = 0 + + this.removeItemObserver() + ;(this.removeItemObserver as unknown) = undefined + + this.removeSyncObserver() + ;(this.removeSyncObserver as unknown) = undefined + + super.deinit() + } + + private popResolveQueue() { + const queue = this.resolveQueue.slice() + this.resolveQueue = [] + return queue + } + + /** + * We only want to resolve singletons for items that are newly created (because this + * is when items proliferate). However, we don't want to resolve immediately on creation, + * but instead wait for the next full sync to complete. This is so that when you download + * a singleton and create the object, but the items key for the item has not yet been + * downloaded, the singleton will be errorDecrypting, and would be mishandled in the + * overall singleton logic. By waiting for a full sync to complete, we can be sure that + * all items keys have been downloaded. + */ + private addObservers() { + this.removeItemObserver = this.itemManager.addObserver(ContentType.Any, ({ inserted, unerrored }) => { + if (unerrored.length > 0) { + this.resolveQueue = this.resolveQueue.concat(unerrored) + } + + if (inserted.length > 0) { + this.resolveQueue = this.resolveQueue.concat(inserted) + } + }) + + this.removeSyncObserver = this.syncService.addEventObserver(async (eventName) => { + if ( + eventName === SyncEvent.DownloadFirstSyncCompleted || + eventName === SyncEvent.SyncCompletedWithAllItemsUploaded + ) { + await this.resolveSingletonsForItems(this.popResolveQueue(), eventName) + } + }) + } + + private validItemsMatchingPredicate( + contentType: ContentType, + predicate: PredicateInterface, + ) { + return this.itemManager.itemsMatchingPredicate(contentType, predicate) + } + + private async resolveSingletonsForItems(items: DecryptedItemInterface[], eventSource: SyncEvent) { + if (items.length === 0) { + return + } + + const handled: DecryptedItemInterface[] = [] + + for (const item of items) { + if (handled.includes(item) || !item.isSingleton) { + continue + } + + const matchingItems = this.validItemsMatchingPredicate( + item.content_type, + item.singletonPredicate(), + ) + + extendArray(handled, matchingItems || []) + + if (!matchingItems || matchingItems.length <= 1) { + continue + } + + await this.handleStrategy(matchingItems, item.singletonStrategy) + } + /** + * Only sync if event source is SyncCompletedWithAllItemsUploaded. + * If it is on DownloadFirstSyncCompleted, we don't need to sync, + * as a sync request will automatically be made as part of the second phase + * of a download-first request. + */ + if (handled.length > 0 && eventSource === SyncEvent.SyncCompletedWithAllItemsUploaded) { + await this.syncService?.sync() + } + } + + private async handleStrategy(items: DecryptedItemInterface[], strategy: SingletonStrategy) { + if (strategy !== SingletonStrategy.KeepEarliest) { + throw 'Unhandled singleton strategy' + } + + const earliestFirst = items.sort((a, b) => { + /** -1: a comes first, 1: b comes first */ + return a.created_at < b.created_at ? -1 : 1 + }) + + const deleteItems = arrayByRemovingFromIndex(earliestFirst, 0) + await this.itemManager.setItemsToBeDeleted(deleteItems) + } + + public findSingleton( + contentType: ContentType, + predicate: PredicateInterface, + ): T | undefined { + const matchingItems = this.validItemsMatchingPredicate(contentType, predicate) + if (matchingItems.length > 0) { + return matchingItems[0] as T + } + return undefined + } + + public async findOrCreateContentTypeSingleton< + C extends ItemContent = ItemContent, + T extends DecryptedItemInterface = DecryptedItemInterface, + >(contentType: ContentType, createContent: ItemContent): Promise { + const existingItems = this.itemManager.getItems(contentType) + + if (existingItems.length > 0) { + return existingItems[0] + } + + /** Item not found, safe to create after full sync has completed */ + if (!this.syncService.getLastSyncDate()) { + /** + * Add a temporary observer in case of long-running sync request, where + * the item we're looking for ends up resolving early or in the middle. + */ + let matchingItem: DecryptedItemInterface | undefined + + const removeObserver = this.itemManager.addObserver(contentType, ({ inserted }) => { + if (inserted.length > 0) { + const matchingItems = inserted.filter((i) => i.content_type === contentType) + + if (matchingItems.length > 0) { + matchingItem = matchingItems[0] + } + } + }) + + await this.syncService.sync() + + removeObserver() + + if (matchingItem) { + return matchingItem as T + } + + /** Check again */ + const refreshedItems = this.itemManager.getItems(contentType) + if (refreshedItems.length > 0) { + return refreshedItems[0] as T + } + } + + /** Delete any items that are errored */ + const errorDecrypting = this.payloadManager.erroredPayloadsForContentType(contentType) + + if (errorDecrypting.length) { + await this.payloadManager.deleteErroredPayloads(errorDecrypting) + } + + /** Safe to create */ + const dirtyPayload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: createContent, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + ...PayloadTimestampDefaults(), + }) + + const item = await this.itemManager.emitItemFromPayload(dirtyPayload, PayloadEmitSource.LocalInserted) + + void this.syncService.sync() + + return item as T + } +} diff --git a/packages/snjs/lib/Services/Storage/DiskStorageService.ts b/packages/snjs/lib/Services/Storage/DiskStorageService.ts new file mode 100644 index 000000000..7d32e1ccb --- /dev/null +++ b/packages/snjs/lib/Services/Storage/DiskStorageService.ts @@ -0,0 +1,482 @@ +import { ContentType, Uuid } from '@standardnotes/common' +import { Copy, extendArray, UuidGenerator } from '@standardnotes/utils' +import { SNLog } from '../../Log' +import { isErrorDecryptingParameters, SNRootKey } from '@standardnotes/encryption' +import * as Encryption from '@standardnotes/encryption' +import * as Services from '@standardnotes/services' +import { DiagnosticInfo, Environment } from '@standardnotes/services' +import { + CreateDecryptedLocalStorageContextPayload, + CreateDeletedLocalStorageContextPayload, + CreateEncryptedLocalStorageContextPayload, + CreatePayloadSplitWithDiscardables, + DecryptedPayload, + EncryptedPayload, + FullyFormedPayloadInterface, + isEncryptedLocalStoragePayload, + ItemContent, + DecryptedPayloadInterface, + DeletedPayloadInterface, + PayloadTimestampDefaults, + LocalStorageEncryptedContextualPayload, +} from '@standardnotes/models' + +/** + * The storage service is responsible for persistence of both simple key-values, and payload + * storage. It does so by relying on deviceInterface to save and retrieve raw values and payloads. + * For simple key/values, items are grouped together in an in-memory hash, and persisted to disk + * as a single object (encrypted, when possible). It handles persisting payloads in the local + * database by encrypting the payloads when possible. + * The storage service also exposes methods that allow the application to initially + * decrypt the persisted key/values, and also a method to determine whether a particular + * key can decrypt wrapped storage. + */ +export class DiskStorageService extends Services.AbstractService implements Services.StorageServiceInterface { + private encryptionProvider!: Encryption.EncryptionProvider + private storagePersistable = false + private persistencePolicy!: Services.StoragePersistencePolicies + private encryptionPolicy!: Services.StorageEncryptionPolicy + private needsPersist = false + private currentPersistPromise?: Promise + + private values!: Services.StorageValuesObject + + constructor( + private deviceInterface: Services.DeviceInterface, + private identifier: string, + private environment: Environment, + protected override internalEventBus: Services.InternalEventBusInterface, + ) { + super(internalEventBus) + void this.setPersistencePolicy(Services.StoragePersistencePolicies.Default) + void this.setEncryptionPolicy(Services.StorageEncryptionPolicy.Default, false) + } + + public provideEncryptionProvider(provider: Encryption.EncryptionProvider): void { + this.encryptionProvider = provider + } + + public override deinit() { + ;(this.deviceInterface as unknown) = undefined + ;(this.encryptionProvider as unknown) = undefined + this.storagePersistable = false + super.deinit() + } + + override async handleApplicationStage(stage: Services.ApplicationStage) { + await super.handleApplicationStage(stage) + + if (stage === Services.ApplicationStage.Launched_10) { + this.storagePersistable = true + if (this.needsPersist) { + void this.persistValuesToDisk() + } + } else if (stage === Services.ApplicationStage.StorageDecrypted_09) { + const persistedPolicy = await this.getValue(Services.StorageKey.StorageEncryptionPolicy) + if (persistedPolicy) { + void this.setEncryptionPolicy(persistedPolicy as Services.StorageEncryptionPolicy, false) + } + } + } + + public async setPersistencePolicy(persistencePolicy: Services.StoragePersistencePolicies) { + this.persistencePolicy = persistencePolicy + + if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) { + await this.deviceInterface.removeAllRawStorageValues() + await this.clearAllPayloads() + } + } + + public setEncryptionPolicy(encryptionPolicy: Services.StorageEncryptionPolicy, persist = true): void { + if (encryptionPolicy === Services.StorageEncryptionPolicy.Disabled && this.environment !== Environment.Mobile) { + throw Error('Disabling storage encryption is only available on mobile.') + } + + this.encryptionPolicy = encryptionPolicy + + if (persist) { + this.setValue(Services.StorageKey.StorageEncryptionPolicy, encryptionPolicy) + } + } + + public isEphemeralSession() { + return this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral + } + + public async initializeFromDisk() { + const value = await this.deviceInterface.getRawStorageValue(this.getPersistenceKey()) + const values = value ? JSON.parse(value as string) : undefined + + this.setInitialValues(values) + } + + /** + * Called by platforms with the value they load from disk, + * after they handle initializeFromDisk + */ + private setInitialValues(values?: Services.StorageValuesObject) { + const sureValues = values || this.defaultValuesObject() + + if (!sureValues[Services.ValueModesKeys.Unwrapped]) { + sureValues[Services.ValueModesKeys.Unwrapped] = {} + } + + this.values = sureValues + } + + public isStorageWrapped(): boolean { + const wrappedValue = this.values[Services.ValueModesKeys.Wrapped] + + return wrappedValue != undefined && isEncryptedLocalStoragePayload(wrappedValue) + } + + public async canDecryptWithKey(key: SNRootKey): Promise { + const wrappedValue = this.values[Services.ValueModesKeys.Wrapped] + + if (!isEncryptedLocalStoragePayload(wrappedValue)) { + throw Error('Attempting to decrypt non decrypted storage value') + } + + const decryptedPayload = await this.decryptWrappedValue(wrappedValue, key) + return !isErrorDecryptingParameters(decryptedPayload) + } + + private async decryptWrappedValue(wrappedValue: LocalStorageEncryptedContextualPayload, key?: SNRootKey) { + /** + * The read content type doesn't matter, so long as we know it responds + * to content type. This allows a more seamless transition when both web + * and mobile used different content types for encrypted storage. + */ + if (!wrappedValue?.content_type) { + throw Error('Attempting to decrypt nonexistent wrapped value') + } + + const payload = new EncryptedPayload({ + ...wrappedValue, + ...PayloadTimestampDefaults(), + content_type: ContentType.EncryptedStorage, + }) + + const split: Encryption.KeyedDecryptionSplit = key + ? { + usesRootKey: { + items: [payload], + key: key, + }, + } + : { + usesRootKeyWithKeyLookup: { + items: [payload], + }, + } + + const decryptedPayload = await this.encryptionProvider.decryptSplitSingle(split) + + return decryptedPayload + } + + public async decryptStorage() { + const wrappedValue = this.values[Services.ValueModesKeys.Wrapped] + + if (!isEncryptedLocalStoragePayload(wrappedValue)) { + throw Error('Attempting to decrypt already decrypted storage') + } + + const decryptedPayload = await this.decryptWrappedValue(wrappedValue) + + if (isErrorDecryptingParameters(decryptedPayload)) { + throw SNLog.error(Error('Unable to decrypt storage.')) + } + + this.values[Services.ValueModesKeys.Unwrapped] = Copy(decryptedPayload.content) + } + + /** @todo This function should be debounced. */ + private async persistValuesToDisk() { + if (!this.storagePersistable) { + this.needsPersist = true + return + } + + if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) { + return + } + + await this.currentPersistPromise + + this.needsPersist = false + + const values = await this.immediatelyPersistValuesToDisk() + + /** Save the persisted value so we have access to it in memory (for unit tests afawk) */ + this.values[Services.ValueModesKeys.Wrapped] = values[Services.ValueModesKeys.Wrapped] + } + + public async awaitPersist(): Promise { + await this.currentPersistPromise + } + + private async immediatelyPersistValuesToDisk(): Promise { + this.currentPersistPromise = this.executeCriticalFunction(async () => { + const values = await this.generatePersistableValues() + + const persistencePolicySuddenlyChanged = this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral + if (persistencePolicySuddenlyChanged) { + return values + } + + await this.deviceInterface?.setRawStorageValue(this.getPersistenceKey(), JSON.stringify(values)) + + return values + }) + + return this.currentPersistPromise + } + + /** + * Generates a payload that can be persisted to disk, + * either as a plain object, or an encrypted item. + */ + private async generatePersistableValues() { + const rawContent = Copy(this.values) as Partial + + const valuesToWrap = rawContent[Services.ValueModesKeys.Unwrapped] + rawContent[Services.ValueModesKeys.Unwrapped] = undefined + + const payload = new DecryptedPayload({ + uuid: UuidGenerator.GenerateUuid(), + content: valuesToWrap as unknown as ItemContent, + content_type: ContentType.EncryptedStorage, + ...PayloadTimestampDefaults(), + }) + + if (this.encryptionProvider.hasRootKeyEncryptionSource()) { + const split: Encryption.KeyedEncryptionSplit = { + usesRootKeyWithKeyLookup: { + items: [payload], + }, + } + + const encryptedPayload = await this.encryptionProvider.encryptSplitSingle(split) + + rawContent[Services.ValueModesKeys.Wrapped] = CreateEncryptedLocalStorageContextPayload(encryptedPayload) + } else { + rawContent[Services.ValueModesKeys.Wrapped] = CreateDecryptedLocalStorageContextPayload(payload) + } + + return rawContent as Services.StorageValuesObject + } + + public setValue(key: string, value: unknown, mode = Services.StorageValueModes.Default): void { + this.setValueWithNoPersist(key, value, mode) + + void this.persistValuesToDisk() + } + + public async setValueAndAwaitPersist( + key: string, + value: unknown, + mode = Services.StorageValueModes.Default, + ): Promise { + this.setValueWithNoPersist(key, value, mode) + + await this.persistValuesToDisk() + } + + private setValueWithNoPersist(key: string, value: unknown, mode = Services.StorageValueModes.Default): void { + if (!this.values) { + throw Error(`Attempting to set storage key ${key} before loading local storage.`) + } + + const domainKey = this.domainKeyForMode(mode) + const domainStorage = this.values[domainKey] + domainStorage[key] = value + } + + public getValue(key: string, mode = Services.StorageValueModes.Default, defaultValue?: T): T { + if (!this.values) { + throw Error(`Attempting to get storage key ${key} before loading local storage.`) + } + + if (!this.values[this.domainKeyForMode(mode)]) { + throw Error(`Storage domain mode not available ${mode} for key ${key}`) + } + + const value = this.values[this.domainKeyForMode(mode)][key] + + return value != undefined ? (value as T) : (defaultValue as T) + } + + public async removeValue(key: string, mode = Services.StorageValueModes.Default): Promise { + if (!this.values) { + throw Error(`Attempting to remove storage key ${key} before loading local storage.`) + } + + const domain = this.values[this.domainKeyForMode(mode)] + + if (domain?.[key]) { + delete domain[key] + return this.persistValuesToDisk() + } + } + + public getStorageEncryptionPolicy() { + return this.encryptionPolicy + } + + /** + * Default persistence key. Platforms can override as needed. + */ + private getPersistenceKey() { + return Services.namespacedKey(this.identifier, Services.RawStorageKey.StorageObject) + } + + private defaultValuesObject( + wrapped?: Services.WrappedStorageValue, + unwrapped?: Services.ValuesObjectRecord, + nonwrapped?: Services.ValuesObjectRecord, + ) { + return DiskStorageService.DefaultValuesObject(wrapped, unwrapped, nonwrapped) + } + + public static DefaultValuesObject( + wrapped: Services.WrappedStorageValue = {} as Services.WrappedStorageValue, + unwrapped: Services.ValuesObjectRecord = {}, + nonwrapped: Services.ValuesObjectRecord = {}, + ) { + return { + [Services.ValueModesKeys.Wrapped]: wrapped, + [Services.ValueModesKeys.Unwrapped]: unwrapped, + [Services.ValueModesKeys.Nonwrapped]: nonwrapped, + } as Services.StorageValuesObject + } + + private domainKeyForMode(mode: Services.StorageValueModes) { + if (mode === Services.StorageValueModes.Default) { + return Services.ValueModesKeys.Unwrapped + } else if (mode === Services.StorageValueModes.Nonwrapped) { + return Services.ValueModesKeys.Nonwrapped + } else { + throw Error('Invalid mode') + } + } + + /** + * Clears simple values from storage only. Does not affect payloads. + */ + async clearValues() { + this.setInitialValues() + await this.immediatelyPersistValuesToDisk() + } + + public async getAllRawPayloads() { + return this.deviceInterface.getAllRawDatabasePayloads(this.identifier) + } + + public async savePayload(payload: FullyFormedPayloadInterface): Promise { + return this.savePayloads([payload]) + } + + public async savePayloads(payloads: FullyFormedPayloadInterface[]): Promise { + if (this.persistencePolicy === Services.StoragePersistencePolicies.Ephemeral) { + return + } + + const { encrypted, decrypted, deleted, discardable } = CreatePayloadSplitWithDiscardables(payloads) + + const encryptionEnabled = this.encryptionPolicy === Services.StorageEncryptionPolicy.Default + const rootKeyEncryptionAvailable = this.encryptionProvider.hasRootKeyEncryptionSource() + + const encryptable: DecryptedPayloadInterface[] = [] + const unencryptable: DecryptedPayloadInterface[] = [] + + if (encryptionEnabled) { + const split = Encryption.SplitPayloadsByEncryptionType(decrypted) + + if (split.itemsKeyEncryption) { + extendArray(encryptable, split.itemsKeyEncryption) + } + + if (split.rootKeyEncryption) { + if (!rootKeyEncryptionAvailable) { + extendArray(unencryptable, split.rootKeyEncryption) + } else { + extendArray(encryptable, split.rootKeyEncryption) + } + } + } else { + extendArray(unencryptable, encryptable) + extendArray(unencryptable, decrypted) + } + + await this.deletePayloads(discardable) + + const split = Encryption.SplitPayloadsByEncryptionType(encryptable) + + const keyLookupSplit = Encryption.CreateEncryptionSplitWithKeyLookup(split) + + const encryptedResults = await this.encryptionProvider.encryptSplit(keyLookupSplit) + + const exportedEncrypted = [...encrypted, ...encryptedResults].map(CreateEncryptedLocalStorageContextPayload) + + const exportedDecrypted = unencryptable.map(CreateDecryptedLocalStorageContextPayload) + + const exportedDeleted = deleted.map(CreateDeletedLocalStorageContextPayload) + + return this.executeCriticalFunction(async () => { + return this.deviceInterface?.saveRawDatabasePayloads( + [...exportedEncrypted, ...exportedDecrypted, ...exportedDeleted], + this.identifier, + ) + }) + } + + public async deletePayloads(payloads: DeletedPayloadInterface[]) { + await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid))) + } + + public async forceDeletePayloads(payloads: FullyFormedPayloadInterface[]) { + await Promise.all(payloads.map((payload) => this.deletePayloadWithId(payload.uuid))) + } + + public async deletePayloadWithId(uuid: Uuid) { + return this.executeCriticalFunction(async () => { + return this.deviceInterface.removeRawDatabasePayloadWithId(uuid, this.identifier) + }) + } + + public async clearAllPayloads() { + return this.executeCriticalFunction(async () => { + return this.deviceInterface.removeAllRawDatabasePayloads(this.identifier) + }) + } + + public clearAllData(): Promise { + return this.executeCriticalFunction(async () => { + await this.clearValues() + await this.clearAllPayloads() + + await this.deviceInterface.removeRawStorageValue( + Services.namespacedKey(this.identifier, Services.RawStorageKey.SnjsVersion), + ) + + await this.deviceInterface.removeRawStorageValue(this.getPersistenceKey()) + }) + } + + override async getDiagnostics(): Promise { + return { + storage: { + storagePersistable: this.storagePersistable, + persistencePolicy: Services.StoragePersistencePolicies[this.persistencePolicy], + encryptionPolicy: Services.StorageEncryptionPolicy[this.encryptionPolicy], + needsPersist: this.needsPersist, + currentPersistPromise: this.currentPersistPromise != undefined, + isStorageWrapped: this.isStorageWrapped(), + allRawPayloadsCount: (await this.getAllRawPayloads()).length, + databaseKeys: await this.deviceInterface.getDatabaseKeys(), + }, + } + } +} diff --git a/packages/snjs/lib/Services/Sync/Account/Operation.ts b/packages/snjs/lib/Services/Sync/Account/Operation.ts new file mode 100644 index 000000000..cacabdf2f --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Account/Operation.ts @@ -0,0 +1,110 @@ +import { ServerSyncPushContextualPayload } from '@standardnotes/models' +import { arrayByDifference, nonSecureRandomIdentifier, subtractFromArray } from '@standardnotes/utils' +import { ServerSyncResponse } from '@Lib/Services/Sync/Account/Response' +import { ResponseSignalReceiver, SyncSignal } from '@Lib/Services/Sync/Signals' +import { SNApiService } from '../../Api/ApiService' +import { RawSyncResponse } from '@standardnotes/responses' + +export const SyncUpDownLimit = 150 + +/** + * A long running operation that handles multiple roundtrips from a server, + * emitting a stream of values that should be acted upon in real time. + */ +export class AccountSyncOperation { + public readonly id = nonSecureRandomIdentifier() + + private pendingPayloads: ServerSyncPushContextualPayload[] + private responses: ServerSyncResponse[] = [] + + /** + * @param payloads An array of payloads to send to the server + * @param receiver A function that receives callback multiple times during the operation + */ + constructor( + private payloads: ServerSyncPushContextualPayload[], + private receiver: ResponseSignalReceiver, + private lastSyncToken: string, + private paginationToken: string, + private apiService: SNApiService, + ) { + this.payloads = payloads + this.lastSyncToken = lastSyncToken + this.paginationToken = paginationToken + this.apiService = apiService + this.receiver = receiver + this.pendingPayloads = payloads.slice() + } + + /** + * Read the payloads that have been saved, or are currently in flight. + */ + get payloadsSavedOrSaving(): ServerSyncPushContextualPayload[] { + return arrayByDifference(this.payloads, this.pendingPayloads) + } + + popPayloads(count: number) { + const payloads = this.pendingPayloads.slice(0, count) + subtractFromArray(this.pendingPayloads, payloads) + return payloads + } + + async run(): Promise { + await this.receiver(SyncSignal.StatusChanged, undefined, { + completedUploadCount: this.totalUploadCount - this.pendingUploadCount, + totalUploadCount: this.totalUploadCount, + }) + const payloads = this.popPayloads(this.upLimit) + + const rawResponse = (await this.apiService.sync( + payloads, + this.lastSyncToken, + this.paginationToken, + this.downLimit, + )) as RawSyncResponse + + const response = new ServerSyncResponse(rawResponse) + this.responses.push(response) + + this.lastSyncToken = response.lastSyncToken as string + this.paginationToken = response.paginationToken as string + + try { + await this.receiver(SyncSignal.Response, response) + } catch (error) { + console.error('Sync handle response error', error) + } + + if (!this.done) { + return this.run() + } + } + + get done() { + return this.pendingPayloads.length === 0 && !this.paginationToken + } + + private get pendingUploadCount() { + return this.pendingPayloads.length + } + + private get totalUploadCount() { + return this.payloads.length + } + + private get upLimit() { + return SyncUpDownLimit + } + + private get downLimit() { + return SyncUpDownLimit + } + + get numberOfItemsInvolved() { + let total = 0 + for (const response of this.responses) { + total += response.numberOfItemsInvolved + } + return total + } +} diff --git a/packages/snjs/lib/Services/Sync/Account/Response.ts b/packages/snjs/lib/Services/Sync/Account/Response.ts new file mode 100644 index 000000000..b11c36081 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Account/Response.ts @@ -0,0 +1,115 @@ +import { + ApiEndpointParam, + ConflictParams, + ConflictType, + Error, + RawSyncResponse, + ServerItemResponse, +} from '@standardnotes/responses' +import { + FilterDisallowedRemotePayloadsAndMap, + CreateServerSyncSavedPayload, + ServerSyncSavedContextualPayload, + FilteredServerItem, +} from '@standardnotes/models' +import { deepFreeze, isNullOrUndefined } from '@standardnotes/utils' + +export class ServerSyncResponse { + public readonly rawResponse: RawSyncResponse + public readonly savedPayloads: ServerSyncSavedContextualPayload[] + public readonly retrievedPayloads: FilteredServerItem[] + public readonly uuidConflictPayloads: FilteredServerItem[] + public readonly dataConflictPayloads: FilteredServerItem[] + public readonly rejectedPayloads: FilteredServerItem[] + + constructor(rawResponse: RawSyncResponse) { + this.rawResponse = rawResponse + + this.savedPayloads = FilterDisallowedRemotePayloadsAndMap(rawResponse.data?.saved_items || []).map((rawItem) => { + return CreateServerSyncSavedPayload(rawItem) + }) + + this.retrievedPayloads = FilterDisallowedRemotePayloadsAndMap(rawResponse.data?.retrieved_items || []) + + this.dataConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawDataConflictItems) + + this.uuidConflictPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawUuidConflictItems) + + this.rejectedPayloads = FilterDisallowedRemotePayloadsAndMap(this.rawRejectedPayloads) + + deepFreeze(this) + } + + public get error(): Error | undefined { + return this.rawResponse.error || this.rawResponse.data?.error + } + + public get status(): number { + return this.rawResponse.status as number + } + + public get lastSyncToken(): string | undefined { + return this.rawResponse.data?.[ApiEndpointParam.LastSyncToken] + } + + public get paginationToken(): string | undefined { + return this.rawResponse.data?.[ApiEndpointParam.PaginationToken] + } + + public get numberOfItemsInvolved(): number { + return this.allFullyFormedPayloads.length + } + + private get allFullyFormedPayloads(): FilteredServerItem[] { + return [ + ...this.retrievedPayloads, + ...this.dataConflictPayloads, + ...this.uuidConflictPayloads, + ...this.rejectedPayloads, + ] + } + + private get rawUuidConflictItems(): ServerItemResponse[] { + return this.rawConflictObjects + .filter((conflict) => { + return conflict.type === ConflictType.UuidConflict + }) + .map((conflict) => { + return conflict.unsaved_item || (conflict.item as ServerItemResponse) + }) + } + + private get rawDataConflictItems(): ServerItemResponse[] { + return this.rawConflictObjects + .filter((conflict) => { + return conflict.type === ConflictType.ConflictingData + }) + .map((conflict) => { + return conflict.server_item || (conflict.item as ServerItemResponse) + }) + } + + private get rawRejectedPayloads(): ServerItemResponse[] { + return this.rawConflictObjects + .filter((conflict) => { + return ( + conflict.type === ConflictType.ContentTypeError || + conflict.type === ConflictType.ContentError || + conflict.type === ConflictType.ReadOnlyError + ) + }) + .map((conflict) => { + return conflict.unsaved_item as ServerItemResponse + }) + } + + private get rawConflictObjects(): ConflictParams[] { + const conflicts = this.rawResponse.data?.conflicts || [] + const legacyConflicts = this.rawResponse.data?.unsaved || [] + return conflicts.concat(legacyConflicts) + } + + public get hasError(): boolean { + return !isNullOrUndefined(this.rawResponse.error) + } +} diff --git a/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts b/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts new file mode 100644 index 000000000..f739513cc --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Account/ResponseResolver.ts @@ -0,0 +1,86 @@ +import { + ImmutablePayloadCollection, + HistoryMap, + DeltaRemoteRetrieved, + DeltaRemoteSaved, + DeltaRemoteDataConflicts, + FullyFormedPayloadInterface, + ServerSyncPushContextualPayload, + ServerSyncSavedContextualPayload, + DeltaRemoteUuidConflicts, + DeltaRemoteRejected, + DeltaEmit, +} from '@standardnotes/models' + +type PayloadSet = { + retrievedPayloads: FullyFormedPayloadInterface[] + savedPayloads: ServerSyncSavedContextualPayload[] + uuidConflictPayloads: FullyFormedPayloadInterface[] + dataConflictPayloads: FullyFormedPayloadInterface[] + rejectedPayloads: FullyFormedPayloadInterface[] +} + +/** + * Given a remote sync response, the resolver applies the incoming changes on top + * of the current base state, and returns what the new global state should look like. + * The response resolver is purely functional and does not modify global state, but instead + * offers the 'recommended' new global state given a sync response and a current base state. + */ +export class ServerSyncResponseResolver { + constructor( + private payloadSet: PayloadSet, + private baseCollection: ImmutablePayloadCollection, + private payloadsSavedOrSaving: ServerSyncPushContextualPayload[], + private historyMap: HistoryMap, + ) {} + + public result(): DeltaEmit[] { + const emits: DeltaEmit[] = [] + + emits.push(this.processRetrievedPayloads()) + emits.push(this.processSavedPayloads()) + emits.push(this.processUuidConflictPayloads()) + emits.push(this.processDataConflictPayloads()) + emits.push(this.processRejectedPayloads()) + + return emits + } + + private processSavedPayloads(): DeltaEmit { + const delta = new DeltaRemoteSaved(this.baseCollection, this.payloadSet.savedPayloads) + + return delta.result() + } + + private processRetrievedPayloads(): DeltaEmit { + const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.retrievedPayloads) + + const delta = new DeltaRemoteRetrieved(this.baseCollection, collection, this.payloadsSavedOrSaving, this.historyMap) + + return delta.result() + } + + private processDataConflictPayloads(): DeltaEmit { + const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.dataConflictPayloads) + + const delta = new DeltaRemoteDataConflicts(this.baseCollection, collection, this.historyMap) + + return delta.result() + } + + private processUuidConflictPayloads(): DeltaEmit { + const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.uuidConflictPayloads) + + const delta = new DeltaRemoteUuidConflicts(this.baseCollection, collection) + + return delta.result() + } + + private processRejectedPayloads(): DeltaEmit { + const collection = ImmutablePayloadCollection.WithPayloads(this.payloadSet.rejectedPayloads) + + const delta = new DeltaRemoteRejected(this.baseCollection, collection) + + return delta.result() + } +} diff --git a/packages/snjs/lib/Services/Sync/Account/Utilities.ts b/packages/snjs/lib/Services/Sync/Account/Utilities.ts new file mode 100644 index 000000000..dc7bd1eb0 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Account/Utilities.ts @@ -0,0 +1,31 @@ +import { + EncryptedPayloadInterface, + DeletedPayloadInterface, + PayloadSource, + DeletedPayload, + EncryptedPayload, + FilteredServerItem, +} from '@standardnotes/models' + +export function CreatePayloadFromRawServerItem( + rawItem: FilteredServerItem, + source: PayloadSource, +): EncryptedPayloadInterface | DeletedPayloadInterface { + if (rawItem.deleted) { + return new DeletedPayload({ ...rawItem, content: undefined, deleted: true }, source) + } else if (rawItem.content != undefined) { + return new EncryptedPayload( + { + ...rawItem, + items_key_id: rawItem.items_key_id, + content: rawItem.content, + deleted: false, + errorDecrypting: false, + waitingForKey: false, + }, + source, + ) + } else { + throw Error('Unhandled case in createPayloadFromRawItem') + } +} diff --git a/packages/snjs/lib/Services/Sync/Offline/Operation.ts b/packages/snjs/lib/Services/Sync/Offline/Operation.ts new file mode 100644 index 000000000..617bb05b7 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Offline/Operation.ts @@ -0,0 +1,29 @@ +import { + CreateOfflineSyncSavedPayload, + DecryptedPayloadInterface, + DeletedPayloadInterface, +} from '@standardnotes/models' +import { ResponseSignalReceiver, SyncSignal } from '@Lib/Services/Sync/Signals' + +import { OfflineSyncResponse } from './Response' + +export class OfflineSyncOperation { + /** + * @param payloads An array of payloads to sync offline + * @param receiver A function that receives callback multiple times during the operation + */ + constructor( + private payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[], + private receiver: ResponseSignalReceiver, + ) {} + + async run() { + const responsePayloads = this.payloads.map((payload) => { + return CreateOfflineSyncSavedPayload(payload) + }) + + const response = new OfflineSyncResponse(responsePayloads) + + await this.receiver(SyncSignal.Response, response) + } +} diff --git a/packages/snjs/lib/Services/Sync/Offline/Response.ts b/packages/snjs/lib/Services/Sync/Offline/Response.ts new file mode 100644 index 000000000..031c6675a --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Offline/Response.ts @@ -0,0 +1,5 @@ +import { OfflineSyncSavedContextualPayload } from '@standardnotes/models' + +export class OfflineSyncResponse { + constructor(public readonly savedPayloads: OfflineSyncSavedContextualPayload[]) {} +} diff --git a/packages/snjs/lib/Services/Sync/Signals.ts b/packages/snjs/lib/Services/Sync/Signals.ts new file mode 100644 index 000000000..6d708eaa6 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Signals.ts @@ -0,0 +1,18 @@ +import { ServerSyncResponse } from '@Lib/Services/Sync/Account/Response' +import { OfflineSyncResponse } from './Offline/Response' + +export enum SyncSignal { + Response = 1, + StatusChanged = 2, +} + +export type SyncStats = { + completedUploadCount: number + totalUploadCount: number +} + +export type ResponseSignalReceiver = ( + signal: SyncSignal, + response?: T, + stats?: SyncStats, +) => Promise diff --git a/packages/snjs/lib/Services/Sync/SyncClientInterface.ts b/packages/snjs/lib/Services/Sync/SyncClientInterface.ts new file mode 100644 index 000000000..597d99867 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/SyncClientInterface.ts @@ -0,0 +1,12 @@ +import { SyncOpStatus } from './SyncOpStatus' +import { SyncOptions } from '@standardnotes/services' + +export interface SyncClientInterface { + sync(options?: Partial): Promise + + isOutOfSync(): boolean + + getLastSyncDate(): Date | undefined + + getSyncStatus(): SyncOpStatus +} diff --git a/packages/snjs/lib/Services/Sync/SyncOpStatus.ts b/packages/snjs/lib/Services/Sync/SyncOpStatus.ts new file mode 100644 index 000000000..91ac7df0b --- /dev/null +++ b/packages/snjs/lib/Services/Sync/SyncOpStatus.ts @@ -0,0 +1,125 @@ +import { SyncEvent, SyncEventReceiver } from '@standardnotes/services' + +const HEALTHY_SYNC_DURATION_THRESHOLD_S = 5 +const TIMING_MONITOR_POLL_FREQUENCY_MS = 500 + +export class SyncOpStatus { + error?: any + private interval: any + private receiver: SyncEventReceiver + private completedUpload = 0 + private totalUpload = 0 + private downloaded = 0 + private databaseLoadCurrent = 0 + private databaseLoadTotal = 0 + private databaseLoadDone = false + private syncing = false + private syncStart!: Date + private timingMonitor?: any + + constructor(interval: any, receiver: SyncEventReceiver) { + this.interval = interval + this.receiver = receiver + } + + public deinit() { + this.stopTimingMonitor() + } + + public setUploadStatus(completed: number, total: number) { + this.completedUpload = completed + this.totalUpload = total + this.receiver(SyncEvent.StatusChanged) + } + + public setDownloadStatus(downloaded: number) { + this.downloaded += downloaded + this.receiver(SyncEvent.StatusChanged) + } + + public setDatabaseLoadStatus(current: number, total: number, done: boolean) { + this.databaseLoadCurrent = current + this.databaseLoadTotal = total + this.databaseLoadDone = done + if (done) { + this.receiver(SyncEvent.LocalDataLoaded) + } else { + this.receiver(SyncEvent.LocalDataIncrementalLoad) + } + } + + public getStats() { + return { + uploadCompletionCount: this.completedUpload, + uploadTotalCount: this.totalUpload, + downloadCount: this.downloaded, + localDataDone: this.databaseLoadDone, + localDataCurrent: this.databaseLoadCurrent, + localDataTotal: this.databaseLoadTotal, + } + } + + public setDidBegin() { + this.syncing = true + this.syncStart = new Date() + } + + public setDidEnd() { + this.syncing = false + } + + get syncInProgress() { + return this.syncing === true + } + + get secondsSinceSyncStart() { + return (new Date().getTime() - this.syncStart.getTime()) / 1000 + } + + /** + * Notifies receiver if current sync request is taking too long to complete. + */ + startTimingMonitor(): void { + if (this.timingMonitor) { + this.stopTimingMonitor() + } + + this.timingMonitor = this.interval(() => { + if (this.secondsSinceSyncStart > HEALTHY_SYNC_DURATION_THRESHOLD_S) { + this.receiver(SyncEvent.SyncTakingTooLong) + this.stopTimingMonitor() + } + }, TIMING_MONITOR_POLL_FREQUENCY_MS) + } + + stopTimingMonitor(): void { + if (Object.prototype.hasOwnProperty.call(this.interval, 'cancel')) { + this.interval.cancel(this.timingMonitor) + } else { + clearInterval(this.timingMonitor) + } + this.timingMonitor = null + } + + hasError(): boolean { + return !!this.error + } + + setError(error: any): void { + this.error = error + } + + clearError() { + this.error = null + } + + reset() { + this.downloaded = 0 + this.completedUpload = 0 + this.totalUpload = 0 + this.syncing = false + this.error = null + this.stopTimingMonitor() + this.receiver(SyncEvent.StatusChanged) + } +} diff --git a/packages/snjs/lib/Services/Sync/SyncService.ts b/packages/snjs/lib/Services/Sync/SyncService.ts new file mode 100644 index 000000000..12a27a62a --- /dev/null +++ b/packages/snjs/lib/Services/Sync/SyncService.ts @@ -0,0 +1,1245 @@ +import { AccountSyncOperation } from '@Lib/Services/Sync/Account/Operation' +import { ContentType } from '@standardnotes/common' +import { + extendArray, + isNotUndefined, + isNullOrUndefined, + removeFromIndex, + sleep, + subtractFromArray, + useBoolean, + Uuids, +} from '@standardnotes/utils' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { OfflineSyncOperation } from '@Lib/Services/Sync/Offline/Operation' +import { PayloadManager } from '../Payloads/PayloadManager' +import { SNApiService } from '../Api/ApiService' +import { SNHistoryManager } from '../History/HistoryManager' +import { SNLog } from '@Lib/Log' +import { SNSessionManager } from '../Session/SessionManager' +import { DiskStorageService } from '../Storage/DiskStorageService' +import { SortPayloadsByRecentAndContentPriority } from '@Lib/Services/Sync/Utils' +import { SyncClientInterface } from './SyncClientInterface' +import { SyncPromise } from './Types' +import { SyncOpStatus } from '@Lib/Services/Sync/SyncOpStatus' +import { ServerSyncResponse } from '@Lib/Services/Sync/Account/Response' +import { ServerSyncResponseResolver } from '@Lib/Services/Sync/Account/ResponseResolver' +import { SyncSignal, SyncStats } from '@Lib/Services/Sync/Signals' +import { UuidString } from '../../Types/UuidString' +import * as Encryption from '@standardnotes/encryption' +import { + PayloadSource, + CreateDecryptedItemFromPayload, + FilterDisallowedRemotePayloadsAndMap, + DeltaOutOfSync, + ImmutablePayloadCollection, + CreatePayload, + FullyFormedTransferPayload, + isEncryptedPayload, + isDecryptedPayload, + EncryptedPayloadInterface, + DecryptedPayloadInterface, + ItemsKeyContent, + FullyFormedPayloadInterface, + DeletedPayloadInterface, + DecryptedPayload, + CreateEncryptedServerSyncPushPayload, + ServerSyncPushContextualPayload, + isDeletedItem, + DeletedItemInterface, + DecryptedItemInterface, + CreatePayloadSplit, + CreateDeletedServerSyncPushPayload, + ItemsKeyInterface, + CreateNonDecryptedPayloadSplit, + DeltaOfflineSaved, + FilteredServerItem, + PayloadEmitSource, + getIncrementedDirtyIndex, + getCurrentDirtyIndex, +} from '@standardnotes/models' +import { + AbstractService, + SyncEvent, + SyncSource, + InternalEventHandlerInterface, + InternalEventBusInterface, + StorageKey, + InternalEventInterface, + IntegrityEvent, + IntegrityEventPayload, + SyncMode, + SyncOptions, + SyncQueueStrategy, + SyncServiceInterface, + DiagnosticInfo, +} from '@standardnotes/services' +import { OfflineSyncResponse } from './Offline/Response' +import { KeyedDecryptionSplit, SplitPayloadsByEncryptionType } from '@standardnotes/encryption' +import { CreatePayloadFromRawServerItem } from './Account/Utilities' +import { ApplicationSyncOptions } from '@Lib/Application/Options/OptionalOptions' + +const DEFAULT_MAJOR_CHANGE_THRESHOLD = 15 +const INVALID_SESSION_RESPONSE_STATUS = 401 + +/** + * The sync service orchestrates with the model manager, api service, and storage service + * to ensure consistent state between the three. When a change is made to an item, consumers + * call the sync service's sync function to first persist pending changes to local storage. + * Then, the items are uploaded to the server. The sync service handles server responses, + * including mapping any retrieved items to application state via model manager mapping. + * After each sync request, any changes made or retrieved are also persisted locally. + * The sync service largely does not perform any task unless it is called upon. + */ +export class SNSyncService + extends AbstractService + implements SyncServiceInterface, InternalEventHandlerInterface, SyncClientInterface +{ + private dirtyIndexAtLastPresyncSave?: number + private lastSyncDate?: Date + private outOfSync = false + private opStatus: SyncOpStatus + + private resolveQueue: SyncPromise[] = [] + private spawnQueue: SyncPromise[] = [] + + /* A DownloadFirst sync must always be the first sync completed */ + public completedOnlineDownloadFirstSync = false + + private majorChangeThreshold = DEFAULT_MAJOR_CHANGE_THRESHOLD + private clientLocked = false + private databaseLoaded = false + + private syncToken?: string + private cursorToken?: string + + private syncLock = false + private _simulate_latency?: { latency: number; enabled: boolean } + private dealloced = false + + public lastSyncInvokationPromise?: Promise + public currentSyncRequestPromise?: Promise + + /** Content types appearing first are always mapped first */ + private readonly localLoadPriorty = [ + ContentType.ItemsKey, + ContentType.UserPrefs, + ContentType.Component, + ContentType.Theme, + ] + + constructor( + private itemManager: ItemManager, + private sessionManager: SNSessionManager, + private protocolService: Encryption.EncryptionService, + private storageService: DiskStorageService, + private payloadManager: PayloadManager, + private apiService: SNApiService, + private historyService: SNHistoryManager, + private readonly options: ApplicationSyncOptions, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + this.opStatus = this.initializeStatus() + } + + /** + * If the database has been newly created (because its new or was previously destroyed) + * we want to reset any sync tokens we have. + */ + public async onNewDatabaseCreated(): Promise { + if (await this.getLastSyncToken()) { + await this.clearSyncPositionTokens() + } + } + + public override deinit(): void { + this.dealloced = true + ;(this.sessionManager as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.payloadManager as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.apiService as unknown) = undefined + this.opStatus.reset() + ;(this.opStatus as unknown) = undefined + this.resolveQueue.length = 0 + this.spawnQueue.length = 0 + super.deinit() + } + + private initializeStatus() { + return new SyncOpStatus(setInterval, (event) => { + void this.notifyEvent(event) + }) + } + + public lockSyncing(): void { + this.clientLocked = true + } + + public unlockSyncing(): void { + this.clientLocked = false + } + + public isOutOfSync(): boolean { + return this.outOfSync + } + + public getLastSyncDate(): Date | undefined { + return this.lastSyncDate + } + + public getSyncStatus(): SyncOpStatus { + return this.opStatus + } + + /** + * Called by application when sign in or registration occurs. + */ + public resetSyncState(): void { + this.dirtyIndexAtLastPresyncSave = undefined + this.lastSyncDate = undefined + this.outOfSync = false + } + + public isDatabaseLoaded(): boolean { + return this.databaseLoaded + } + + /** + * Used in tandem with `loadDatabasePayloads` + */ + public async getDatabasePayloads(): Promise { + return this.storageService.getAllRawPayloads().catch((error) => { + void this.notifyEvent(SyncEvent.DatabaseReadError, error) + throw error + }) + } + + private async processItemsKeysFirstDuringDatabaseLoad( + itemsKeysPayloads: FullyFormedPayloadInterface[], + ): Promise { + const encryptedItemsKeysPayloads = itemsKeysPayloads.filter(isEncryptedPayload) + + const originallyDecryptedItemsKeysPayloads = itemsKeysPayloads.filter( + isDecryptedPayload, + ) as DecryptedPayloadInterface[] + + const itemsKeysSplit: Encryption.KeyedDecryptionSplit = { + usesRootKeyWithKeyLookup: { + items: encryptedItemsKeysPayloads, + }, + } + + const newlyDecryptedItemsKeys = await this.protocolService.decryptSplit(itemsKeysSplit) + + await this.payloadManager.emitPayloads( + [...originallyDecryptedItemsKeysPayloads, ...newlyDecryptedItemsKeys], + PayloadEmitSource.LocalDatabaseLoaded, + ) + } + + /** + * @param rawPayloads - use `getDatabasePayloads` to get these payloads. + * They are fed as a parameter so that callers don't have to await the loading, but can + * await getting the raw payloads from storage + */ + public async loadDatabasePayloads(rawPayloads: FullyFormedTransferPayload[]): Promise { + if (this.databaseLoaded) { + throw 'Attempting to initialize already initialized local database.' + } + + if (rawPayloads.length === 0) { + this.databaseLoaded = true + this.opStatus.setDatabaseLoadStatus(0, 0, true) + return + } + + const unsortedPayloads = rawPayloads + .map((rawPayload) => { + try { + return CreatePayload(rawPayload, PayloadSource.Constructor) + } catch (e) { + console.error('Creating payload fail+ed', e) + return undefined + } + }) + .filter(isNotUndefined) + + const payloads = SortPayloadsByRecentAndContentPriority(unsortedPayloads, this.localLoadPriorty) + + const itemsKeysPayloads = payloads.filter((payload) => { + return payload.content_type === ContentType.ItemsKey + }) + + subtractFromArray(payloads, itemsKeysPayloads) + + await this.processItemsKeysFirstDuringDatabaseLoad(itemsKeysPayloads) + + /** + * Map in batches to give interface a chance to update. Note that total decryption + * time is constant regardless of batch size. Decrypting 3000 items all at once or in + * batches will result in the same time spent. It's the emitting/painting/rendering + * that requires batch size optimization. + */ + const payloadCount = payloads.length + const batchSize = this.options.loadBatchSize + const numBatches = Math.ceil(payloadCount / batchSize) + + for (let batchIndex = 0; batchIndex < numBatches; batchIndex++) { + const currentPosition = batchIndex * batchSize + const batch = payloads.slice(currentPosition, currentPosition + batchSize) + const encrypted: EncryptedPayloadInterface[] = [] + const nonencrypted: (DecryptedPayloadInterface | DeletedPayloadInterface)[] = [] + + for (const payload of batch) { + if (isEncryptedPayload(payload)) { + encrypted.push(payload) + } else { + nonencrypted.push(payload) + } + } + + const split: Encryption.KeyedDecryptionSplit = { + usesItemsKeyWithKeyLookup: { + items: encrypted, + }, + } + + const results = await this.protocolService.decryptSplit(split) + + await this.payloadManager.emitPayloads([...nonencrypted, ...results], PayloadEmitSource.LocalDatabaseLoaded) + + void this.notifyEvent(SyncEvent.LocalDataIncrementalLoad) + + this.opStatus.setDatabaseLoadStatus(currentPosition, payloadCount, false) + + await sleep(1, false) + } + + this.databaseLoaded = true + this.opStatus.setDatabaseLoadStatus(0, 0, true) + } + + private setLastSyncToken(token: string) { + this.syncToken = token + return this.storageService.setValue(StorageKey.LastSyncToken, token) + } + + private async setPaginationToken(token: string) { + this.cursorToken = token + if (token) { + return this.storageService.setValue(StorageKey.PaginationToken, token) + } else { + return this.storageService.removeValue(StorageKey.PaginationToken) + } + } + + private async getLastSyncToken(): Promise { + if (!this.syncToken) { + this.syncToken = (await this.storageService.getValue(StorageKey.LastSyncToken)) as string + } + return this.syncToken + } + + private async getPaginationToken(): Promise { + if (!this.cursorToken) { + this.cursorToken = (await this.storageService.getValue(StorageKey.PaginationToken)) as string + } + return this.cursorToken + } + + private async clearSyncPositionTokens() { + this.syncToken = undefined + this.cursorToken = undefined + await this.storageService.removeValue(StorageKey.LastSyncToken) + await this.storageService.removeValue(StorageKey.PaginationToken) + } + + private itemsNeedingSync() { + return this.itemManager.getDirtyItems() + } + + public async markAllItemsAsNeedingSyncAndPersist(): Promise { + this.log('Marking all items as needing sync') + + const items = this.itemManager.items + const payloads = items.map((item) => { + return new DecryptedPayload({ + ...item.payload.ejected(), + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + }) + }) + + await this.payloadManager.emitPayloads(payloads, PayloadEmitSource.LocalChanged) + await this.persistPayloads(payloads) + } + + /** + * Return the payloads that need local persistence, before beginning a sync. + * This way, if the application is closed before a sync request completes, + * pending data will be saved to disk, and synced the next time the app opens. + */ + private popPayloadsNeedingPreSyncSave(from: (DecryptedPayloadInterface | DeletedPayloadInterface)[]) { + const lastPreSyncSave = this.dirtyIndexAtLastPresyncSave + if (lastPreSyncSave == undefined) { + return from + } + + const payloads = from.filter((candidate) => { + return !candidate.dirtyIndex || candidate.dirtyIndex > lastPreSyncSave + }) + + this.dirtyIndexAtLastPresyncSave = getCurrentDirtyIndex() + + return payloads + } + + private queueStrategyResolveOnNext(): Promise { + return new Promise((resolve, reject) => { + this.resolveQueue.push({ resolve, reject }) + }) + } + + private queueStrategyForceSpawnNew(options: SyncOptions) { + return new Promise((resolve, reject) => { + this.spawnQueue.push({ resolve, reject, options }) + }) + } + + /** + * For timing strategy SyncQueueStrategy.ForceSpawnNew, we will execute a whole sync request + * and pop it from the queue. + */ + private popSpawnQueue() { + if (this.spawnQueue.length === 0) { + return null + } + + const promise = this.spawnQueue[0] + removeFromIndex(this.spawnQueue, 0) + this.log('Syncing again from spawn queue') + + return this.sync({ + queueStrategy: SyncQueueStrategy.ForceSpawnNew, + source: SyncSource.SpawnQueue, + ...promise.options, + }) + .then(() => { + promise.resolve() + }) + .catch(() => { + promise.reject() + }) + } + + private async payloadsByPreparingForServer( + payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[], + ): Promise { + const payloadSplit = CreatePayloadSplit(payloads) + + const encryptionSplit = Encryption.SplitPayloadsByEncryptionType(payloadSplit.decrypted) + + const keyLookupSplit = Encryption.CreateEncryptionSplitWithKeyLookup(encryptionSplit) + + const encryptedResults = await this.protocolService.encryptSplit(keyLookupSplit) + + const contextPayloads = [ + ...encryptedResults.map(CreateEncryptedServerSyncPushPayload), + ...payloadSplit.deleted.map(CreateDeletedServerSyncPushPayload), + ] + + return contextPayloads + } + + public async downloadFirstSync(waitTimeOnFailureMs: number, otherSyncOptions?: Partial): Promise { + const maxTries = 5 + + for (let i = 0; i < maxTries; i++) { + await this.sync({ + mode: SyncMode.DownloadFirst, + queueStrategy: SyncQueueStrategy.ForceSpawnNew, + source: SyncSource.External, + ...otherSyncOptions, + }).catch(console.error) + + if (this.completedOnlineDownloadFirstSync) { + return + } else { + await sleep(waitTimeOnFailureMs) + } + } + + console.error(`Failed downloadFirstSync after ${maxTries} tries`) + } + + public async awaitCurrentSyncs(): Promise { + await this.lastSyncInvokationPromise + await this.currentSyncRequestPromise + } + + public async sync(options: Partial = {}): Promise { + if (this.clientLocked) { + this.log('Sync locked by client') + return + } + + const fullyResolvedOptions: SyncOptions = { + source: SyncSource.External, + ...options, + } + + this.lastSyncInvokationPromise = this.performSync(fullyResolvedOptions) + return this.lastSyncInvokationPromise + } + + private async prepareForSync(options: SyncOptions) { + const items = this.itemsNeedingSync() + + /** + * Freeze the begin date immediately after getting items needing sync. This way an + * item dirtied at any point after this date is marked as needing another sync + */ + const beginDate = new Date() + const frozenDirtyIndex = getCurrentDirtyIndex() + + /** + * Items that have never been synced and marked as deleted should not be + * uploaded to server, and instead deleted directly after sync completion. + */ + const neverSyncedDeleted: DeletedItemInterface[] = items.filter((item) => { + return item.neverSynced && isDeletedItem(item) + }) as DeletedItemInterface[] + + subtractFromArray(items, neverSyncedDeleted) + + const decryptedPayloads = items.map((item) => { + return item.payloadRepresentation() + }) + + const payloadsNeedingSave = this.popPayloadsNeedingPreSyncSave(decryptedPayloads) + + await this.persistPayloads(payloadsNeedingSave) + + if (options.onPresyncSave) { + options.onPresyncSave() + } + + return { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } + } + + /** + * Allows us to lock this function from triggering duplicate network requests. + * There are two types of locking checks: + * 1. syncLocked(): If a call to sync() call has begun preparing to be sent to the server. + * but not yet completed all the code below before reaching that point. + * (before reaching opStatus.setDidBegin). + * 2. syncOpInProgress: If a sync() call is in flight to the server. + */ + private configureSyncLock() { + const syncInProgress = this.opStatus.syncInProgress + const databaseLoaded = this.databaseLoaded + const canExecuteSync = !this.syncLock + const shouldExecuteSync = canExecuteSync && databaseLoaded && !syncInProgress + + if (shouldExecuteSync) { + this.syncLock = true + } else { + this.log( + !canExecuteSync + ? 'Another function call has begun preparing for sync.' + : syncInProgress + ? 'Attempting to sync while existing sync in progress.' + : 'Attempting to sync before local database has loaded.', + ) + } + + const releaseLock = () => { + this.syncLock = false + } + + return { shouldExecuteSync, releaseLock } + } + + private deferSyncRequest(options: SyncOptions) { + const useStrategy = !isNullOrUndefined(options.queueStrategy) + ? options.queueStrategy + : SyncQueueStrategy.ResolveOnNext + + if (useStrategy === SyncQueueStrategy.ResolveOnNext) { + return this.queueStrategyResolveOnNext() + } else if (useStrategy === SyncQueueStrategy.ForceSpawnNew) { + return this.queueStrategyForceSpawnNew({ + mode: options.mode, + checkIntegrity: options.checkIntegrity, + source: options.source, + }) + } else { + throw Error(`Unhandled timing strategy ${useStrategy}`) + } + } + + private async prepareForSyncExecution( + items: (DecryptedItemInterface | DeletedItemInterface)[], + inTimeResolveQueue: SyncPromise[], + beginDate: Date, + frozenDirtyIndex: number, + ) { + this.opStatus.setDidBegin() + + await this.notifyEvent(SyncEvent.SyncWillBegin) + + /** + * Subtract from array as soon as we're sure they'll be called. + * resolves are triggered at the end of this function call + */ + subtractFromArray(this.resolveQueue, inTimeResolveQueue) + + /** + * lastSyncBegan must be set *after* any point we may have returned above. + * Setting this value means the item was 100% sent to the server. + */ + if (items.length > 0) { + return this.itemManager.setLastSyncBeganForItems(items, beginDate, frozenDirtyIndex) + } else { + return items + } + } + + /** + * The InTime resolve queue refers to any sync requests that were made while we still + * have not sent out the current request. So, anything in the InTime resolve queue + * will have made it "in time" to piggyback on the current request. Anything that comes + * after InTime will schedule a new sync request. + */ + private getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest() { + return this.resolveQueue.slice() + } + + private getOfflineSyncParameters( + payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[], + mode: SyncMode = SyncMode.Default, + ): { + uploadPayloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[] + } { + const uploadPayloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[] = + mode === SyncMode.Default ? payloads : [] + + return { uploadPayloads } + } + + private createOfflineSyncOperation( + payloads: (DeletedPayloadInterface | DecryptedPayloadInterface)[], + source: SyncSource, + mode: SyncMode = SyncMode.Default, + ) { + this.log('Syncing offline user', 'source:', source, 'mode:', mode, 'payloads:', payloads) + + const operation = new OfflineSyncOperation(payloads, async (type, response) => { + if (this.dealloced) { + return + } + if (type === SyncSignal.Response && response) { + await this.handleOfflineResponse(response) + } + }) + + return operation + } + + private async getOnlineSyncParameters( + payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[], + mode: SyncMode = SyncMode.Default, + ): Promise<{ + uploadPayloads: ServerSyncPushContextualPayload[] + syncMode: SyncMode + }> { + const useMode = !this.completedOnlineDownloadFirstSync ? SyncMode.DownloadFirst : mode + + if (useMode === SyncMode.Default && !this.completedOnlineDownloadFirstSync) { + throw Error('Attempting to default mode sync without having completed initial.') + } + + const uploadPayloads: ServerSyncPushContextualPayload[] = + useMode === SyncMode.Default ? await this.payloadsByPreparingForServer(payloads) : [] + + return { uploadPayloads, syncMode: useMode } + } + + private async createServerSyncOperation( + payloads: ServerSyncPushContextualPayload[], + checkIntegrity: boolean, + source: SyncSource, + mode: SyncMode = SyncMode.Default, + ) { + const syncToken = await this.getLastSyncToken() + const paginationToken = await this.getPaginationToken() + + const operation = new AccountSyncOperation( + payloads, + async (type: SyncSignal, response?: ServerSyncResponse, stats?: SyncStats) => { + switch (type) { + case SyncSignal.Response: + if (this.dealloced) { + return + } + if (response?.hasError) { + this.handleErrorServerResponse(response) + } else if (response) { + await this.handleSuccessServerResponse(operation, response) + } + break + case SyncSignal.StatusChanged: + if (stats) { + this.opStatus.setUploadStatus(stats.completedUploadCount, stats.totalUploadCount) + } + break + } + }, + syncToken, + paginationToken, + this.apiService, + ) + + this.log( + 'Syncing online user', + 'source', + SyncSource[source], + 'operation id', + operation.id, + 'integrity check', + checkIntegrity, + 'mode', + SyncMode[mode], + 'syncToken', + syncToken, + 'cursorToken', + paginationToken, + 'payloads', + payloads, + ) + + return operation + } + + private async createSyncOperation( + payloads: (DecryptedPayloadInterface | DeletedPayloadInterface)[], + online: boolean, + options: SyncOptions, + ): Promise<{ operation: AccountSyncOperation | OfflineSyncOperation; mode: SyncMode }> { + if (online) { + const { uploadPayloads, syncMode } = await this.getOnlineSyncParameters(payloads, options.mode) + + return { + operation: await this.createServerSyncOperation( + uploadPayloads, + useBoolean(options.checkIntegrity, false), + options.source, + syncMode, + ), + mode: syncMode, + } + } else { + const { uploadPayloads } = this.getOfflineSyncParameters(payloads, options.mode) + + return { + operation: this.createOfflineSyncOperation(uploadPayloads, options.source, options.mode), + mode: options.mode || SyncMode.Default, + } + } + } + + private async performSync(options: SyncOptions): Promise { + const { shouldExecuteSync, releaseLock } = this.configureSyncLock() + + const { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } = await this.prepareForSync(options) + + const inTimeResolveQueue = this.getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest() + + if (!shouldExecuteSync) { + return this.deferSyncRequest(options) + } + + if (this.dealloced) { + return + } + + const latestItems = await this.prepareForSyncExecution(items, inTimeResolveQueue, beginDate, frozenDirtyIndex) + + const online = this.sessionManager.online() + + const { operation, mode: syncMode } = await this.createSyncOperation( + latestItems.map((i) => i.payloadRepresentation()), + online, + options, + ) + + const operationPromise = operation.run() + + this.currentSyncRequestPromise = operationPromise + + await operationPromise + + if (this.dealloced) { + return + } + + releaseLock() + + const { hasError } = await this.handleSyncOperationFinish(operation, options, neverSyncedDeleted, syncMode) + if (hasError) { + return + } + + const didSyncAgain = await this.potentiallySyncAgainAfterSyncCompletion( + syncMode, + options, + inTimeResolveQueue, + online, + ) + if (didSyncAgain) { + return + } + + if (options.checkIntegrity) { + await this.notifyEventSync(SyncEvent.SyncRequestsIntegrityCheck, { + source: options.source as SyncSource, + }) + } + + await this.notifyEventSync(SyncEvent.SyncCompletedWithAllItemsUploadedAndDownloaded, { + source: options.source, + }) + + this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue) + + return undefined + } + + private async handleOfflineResponse(response: OfflineSyncResponse) { + this.log('Offline Sync Response', response) + + const masterCollection = this.payloadManager.getMasterCollection() + + const delta = new DeltaOfflineSaved(masterCollection, response.savedPayloads) + + const emit = delta.result() + + const payloadsToPersist = await this.payloadManager.emitDeltaEmit(emit) + + await this.persistPayloads(payloadsToPersist) + + this.opStatus.clearError() + + await this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response) + } + + private handleErrorServerResponse(response: ServerSyncResponse) { + this.log('Sync Error', response) + + if (response.status === INVALID_SESSION_RESPONSE_STATUS) { + void this.notifyEvent(SyncEvent.InvalidSession) + } + + this.opStatus?.setError(response.error) + + void this.notifyEvent(SyncEvent.SyncError, response) + } + + private async handleSuccessServerResponse(operation: AccountSyncOperation, response: ServerSyncResponse) { + if (this._simulate_latency) { + await sleep(this._simulate_latency.latency) + } + + this.opStatus.clearError() + + this.opStatus.setDownloadStatus(response.retrievedPayloads.length) + + const masterCollection = this.payloadManager.getMasterCollection() + + const historyMap = this.historyService.getHistoryMapCopy() + + const resolver = new ServerSyncResponseResolver( + { + retrievedPayloads: await this.processServerPayloads(response.retrievedPayloads, PayloadSource.RemoteRetrieved), + savedPayloads: response.savedPayloads, + uuidConflictPayloads: await this.processServerPayloads( + response.uuidConflictPayloads, + PayloadSource.RemoteRetrieved, + ), + dataConflictPayloads: await this.processServerPayloads( + response.dataConflictPayloads, + PayloadSource.RemoteRetrieved, + ), + rejectedPayloads: await this.processServerPayloads(response.rejectedPayloads, PayloadSource.RemoteRetrieved), + }, + masterCollection, + operation.payloadsSavedOrSaving, + historyMap, + ) + + this.log( + 'Online Sync Response', + 'Operator ID', + operation.id, + response.rawResponse.data, + 'Decrypted payloads', + resolver['payloadSet'], + ) + + const emits = resolver.result() + + for (const emit of emits) { + const payloadsToPersist = await this.payloadManager.emitDeltaEmit(emit) + + await this.persistPayloads(payloadsToPersist) + } + + await Promise.all([ + this.setLastSyncToken(response.lastSyncToken as string), + this.setPaginationToken(response.paginationToken as string), + this.notifyEvent(SyncEvent.SingleRoundTripSyncCompleted, response), + ]) + } + + private async processServerPayloads( + items: FilteredServerItem[], + source: PayloadSource, + ): Promise { + const payloads = items.map((i) => CreatePayloadFromRawServerItem(i, source)) + + const { encrypted, deleted } = CreateNonDecryptedPayloadSplit(payloads) + + const results: FullyFormedPayloadInterface[] = [...deleted] + + const { rootKeyEncryption, itemsKeyEncryption } = SplitPayloadsByEncryptionType(encrypted) + + const { results: rootKeyDecryptionResults, map: processedItemsKeys } = await this.decryptServerItemsKeys( + rootKeyEncryption || [], + ) + + extendArray(results, rootKeyDecryptionResults) + + if (itemsKeyEncryption) { + const decryptionResults = await this.decryptProcessedServerPayloads(itemsKeyEncryption, processedItemsKeys) + extendArray(results, decryptionResults) + } + + return results + } + + private async decryptServerItemsKeys(payloads: EncryptedPayloadInterface[]) { + const map: Record> = {} + + if (payloads.length === 0) { + return { + results: [], + map, + } + } + + const rootKeySplit: KeyedDecryptionSplit = { + usesRootKeyWithKeyLookup: { + items: payloads, + }, + } + + const results = await this.protocolService.decryptSplit(rootKeySplit) + + results.forEach((result) => { + if (isDecryptedPayload(result) && result.content_type === ContentType.ItemsKey) { + map[result.uuid] = result + } + }) + + return { + results, + map, + } + } + + private async decryptProcessedServerPayloads( + payloads: EncryptedPayloadInterface[], + map: Record>, + ): Promise<(EncryptedPayloadInterface | DecryptedPayloadInterface)[]> { + return Promise.all( + payloads.map(async (encrypted) => { + const previouslyProcessedItemsKey: DecryptedPayloadInterface | undefined = + map[encrypted.items_key_id as string] + + const itemsKey = previouslyProcessedItemsKey + ? (CreateDecryptedItemFromPayload(previouslyProcessedItemsKey) as ItemsKeyInterface) + : undefined + + const keyedSplit: Encryption.KeyedDecryptionSplit = {} + if (itemsKey) { + keyedSplit.usesItemsKey = { + items: [encrypted], + key: itemsKey, + } + } else { + keyedSplit.usesItemsKeyWithKeyLookup = { + items: [encrypted], + } + } + + return this.protocolService.decryptSplitSingle(keyedSplit) + }), + ) + } + + private async handleSyncOperationFinish( + operation: AccountSyncOperation | OfflineSyncOperation, + options: SyncOptions, + neverSyncedDeleted: DeletedItemInterface[], + syncMode: SyncMode, + ) { + this.opStatus.setDidEnd() + + if (this.opStatus.hasError()) { + return { hasError: true } + } + + this.opStatus.reset() + + this.lastSyncDate = new Date() + + if (operation instanceof AccountSyncOperation && operation.numberOfItemsInvolved >= this.majorChangeThreshold) { + void this.notifyEvent(SyncEvent.MajorDataChange) + } + + if (neverSyncedDeleted.length > 0) { + await this.handleNeverSyncedDeleted(neverSyncedDeleted) + } + + if (syncMode !== SyncMode.DownloadFirst) { + await this.notifyEvent(SyncEvent.SyncCompletedWithAllItemsUploaded, { + source: options.source, + }) + } + + return { hasError: false } + } + + private async handleDownloadFirstCompletionAndSyncAgain(online: boolean, options: SyncOptions) { + if (online) { + this.completedOnlineDownloadFirstSync = true + } + await this.notifyEvent(SyncEvent.DownloadFirstSyncCompleted) + await this.sync({ + source: SyncSource.AfterDownloadFirst, + checkIntegrity: true, + awaitAll: options.awaitAll, + }) + } + + private async syncAgainByHandlingRequestsWaitingInResolveQueue(options: SyncOptions) { + this.log('Syncing again from resolve queue') + const promise = this.sync({ + source: SyncSource.ResolveQueue, + checkIntegrity: options.checkIntegrity, + }) + if (options.awaitAll) { + await promise + } + } + + /** + * As part of the just concluded sync operation, more items may have + * been dirtied (like conflicts), and the caller may want to await the + * full resolution of these items. + */ + private async syncAgainByHandlingNewDirtyItems(options: SyncOptions) { + await this.sync({ + source: SyncSource.MoreDirtyItems, + checkIntegrity: options.checkIntegrity, + awaitAll: options.awaitAll, + }) + } + + /** + * For timing strategy SyncQueueStrategy.ResolveOnNext. + * Execute any callbacks pulled before this sync request began. + * Calling resolve on the callbacks should be the last thing we do in this function, + * to simulate calling .sync as if it went through straight to the end without having + * to be queued. + */ + private resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue: SyncPromise[]) { + for (const callback of inTimeResolveQueue) { + callback.resolve() + } + } + + private async potentiallySyncAgainAfterSyncCompletion( + syncMode: SyncMode, + options: SyncOptions, + inTimeResolveQueue: SyncPromise[], + online: boolean, + ) { + if (syncMode === SyncMode.DownloadFirst) { + await this.handleDownloadFirstCompletionAndSyncAgain(online, options) + this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue) + return true + } + + const didSpawnNewRequest = this.popSpawnQueue() + const resolveQueueHasRequestsThatDidntMakeItInTime = this.resolveQueue.length > 0 + if (!didSpawnNewRequest && resolveQueueHasRequestsThatDidntMakeItInTime) { + await this.syncAgainByHandlingRequestsWaitingInResolveQueue(options) + this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue) + return true + } + + const newItemsNeedingSync = this.itemsNeedingSync() + if (newItemsNeedingSync.length > 0) { + await this.syncAgainByHandlingNewDirtyItems(options) + this.resolvePendingSyncRequestsThatMadeItInTimeOfCurrentRequest(inTimeResolveQueue) + return true + } + + return false + } + + /** + * Items that have never been synced and marked as deleted should be cleared + * as dirty, mapped, then removed from storage. + */ + private async handleNeverSyncedDeleted(items: DeletedItemInterface[]) { + const payloads = items.map((item) => { + return item.payloadRepresentation({ + dirty: false, + }) + }) + + await this.payloadManager.emitPayloads(payloads, PayloadEmitSource.LocalChanged) + await this.persistPayloads(payloads) + } + + public async persistPayloads(payloads: FullyFormedPayloadInterface[]) { + if (payloads.length === 0 || this.dealloced) { + return + } + + return this.storageService.savePayloads(payloads).catch((error) => { + void this.notifyEvent(SyncEvent.DatabaseWriteError, error) + SNLog.error(error) + }) + } + + setInSync(isInSync: boolean): void { + if (isInSync === !this.outOfSync) { + return + } + + if (isInSync) { + this.outOfSync = false + void this.notifyEvent(SyncEvent.ExitOutOfSync) + } else { + this.outOfSync = true + void this.notifyEvent(SyncEvent.EnterOutOfSync) + } + } + + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === IntegrityEvent.IntegrityCheckCompleted) { + await this.handleIntegrityCheckEventResponse(event.payload as IntegrityEventPayload) + } + } + + private async handleIntegrityCheckEventResponse(eventPayload: IntegrityEventPayload) { + const rawPayloads = eventPayload.rawPayloads + + if (rawPayloads.length === 0) { + this.setInSync(true) + return + } + + const receivedPayloads = FilterDisallowedRemotePayloadsAndMap(rawPayloads).map((rawPayload) => { + return CreatePayloadFromRawServerItem(rawPayload, PayloadSource.RemoteRetrieved) + }) + + const payloadSplit = CreateNonDecryptedPayloadSplit(receivedPayloads) + + const encryptionSplit = Encryption.SplitPayloadsByEncryptionType(payloadSplit.encrypted) + + const keyedSplit = Encryption.CreateDecryptionSplitWithKeyLookup(encryptionSplit) + + const decryptionResults = await this.protocolService.decryptSplit(keyedSplit) + + this.setInSync(false) + + await this.emitOutOfSyncRemotePayloads([...decryptionResults, ...payloadSplit.deleted]) + + const shouldCheckIntegrityAgainAfterSync = eventPayload.source !== SyncSource.ResolveOutOfSync + + await this.sync({ + checkIntegrity: shouldCheckIntegrityAgainAfterSync, + source: SyncSource.ResolveOutOfSync, + }) + } + + private async emitOutOfSyncRemotePayloads(payloads: FullyFormedPayloadInterface[]) { + const delta = new DeltaOutOfSync( + this.payloadManager.getMasterCollection(), + ImmutablePayloadCollection.WithPayloads(payloads), + this.historyService.getHistoryMapCopy(), + ) + + const emit = delta.result() + + await this.payloadManager.emitDeltaEmit(emit) + + await this.persistPayloads(emit.emits) + } + + override async getDiagnostics(): Promise { + const dirtyUuids = Uuids(this.itemsNeedingSync()) + + return { + sync: { + syncToken: await this.getLastSyncToken(), + cursorToken: await this.getPaginationToken(), + dirtyIndexAtLastPresyncSave: this.dirtyIndexAtLastPresyncSave, + lastSyncDate: this.lastSyncDate, + outOfSync: this.outOfSync, + completedOnlineDownloadFirstSync: this.completedOnlineDownloadFirstSync, + clientLocked: this.clientLocked, + databaseLoaded: this.databaseLoaded, + syncLock: this.syncLock, + dealloced: this.dealloced, + itemsNeedingSync: dirtyUuids, + itemsNeedingSyncCount: dirtyUuids.length, + pendingRequestCount: this.resolveQueue.length + this.spawnQueue.length, + }, + } + } + + /** @e2e_testing */ + // eslint-disable-next-line camelcase + ut_setDatabaseLoaded(loaded: boolean): void { + this.databaseLoaded = loaded + } + + /** @e2e_testing */ + // eslint-disable-next-line camelcase + ut_clearLastSyncDate(): void { + this.lastSyncDate = undefined + } + + /** @e2e_testing */ + // eslint-disable-next-line camelcase + ut_beginLatencySimulator(latency: number): void { + this._simulate_latency = { + latency: latency || 1000, + enabled: true, + } + } + + /** @e2e_testing */ + // eslint-disable-next-line camelcase + ut_endLatencySimulator(): void { + this._simulate_latency = undefined + } +} diff --git a/packages/snjs/lib/Services/Sync/Types.ts b/packages/snjs/lib/Services/Sync/Types.ts new file mode 100644 index 000000000..7967c14e4 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Types.ts @@ -0,0 +1,7 @@ +import { SyncOptions } from '@standardnotes/services' + +export type SyncPromise = { + resolve: (value?: unknown) => void + reject: () => void + options?: SyncOptions +} diff --git a/packages/snjs/lib/Services/Sync/Utils.ts b/packages/snjs/lib/Services/Sync/Utils.ts new file mode 100644 index 000000000..a12d7fda7 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/Utils.ts @@ -0,0 +1,43 @@ +import { ContentType } from '@standardnotes/common' +import { FullyFormedPayloadInterface } from '@standardnotes/models' + +/** + * Sorts payloads according by most recently modified first, according to the priority, + * whereby the earlier a content_type appears in the priorityList, + * the earlier it will appear in the resulting sorted array. + */ +export function SortPayloadsByRecentAndContentPriority( + payloads: FullyFormedPayloadInterface[], + priorityList: ContentType[], +): FullyFormedPayloadInterface[] { + return payloads.sort((a, b) => { + const dateResult = new Date(b.serverUpdatedAt).getTime() - new Date(a.serverUpdatedAt).getTime() + + let aPriority = 0 + let bPriority = 0 + + if (priorityList) { + aPriority = priorityList.indexOf(a.content_type) + bPriority = priorityList.indexOf(b.content_type) + + if (aPriority === -1) { + /** Not found in list, not prioritized. Set it to max value */ + aPriority = priorityList.length + } + if (bPriority === -1) { + /** Not found in list, not prioritized. Set it to max value */ + bPriority = priorityList.length + } + } + + if (aPriority === bPriority) { + return dateResult + } + + if (aPriority < bPriority) { + return -1 + } else { + return 1 + } + }) +} diff --git a/packages/snjs/lib/Services/Sync/index.ts b/packages/snjs/lib/Services/Sync/index.ts new file mode 100644 index 000000000..32acb4930 --- /dev/null +++ b/packages/snjs/lib/Services/Sync/index.ts @@ -0,0 +1,9 @@ +export * from './SyncService' +export * from './Types' +export * from './SyncOpStatus' +export * from './SyncClientInterface' +export * from './Account/Operation' +export * from './Account/ResponseResolver' +export * from './Offline/Operation' +export * from './Utils' +export * from './Account/Response' diff --git a/packages/snjs/lib/Services/User/UserServerInterface.ts b/packages/snjs/lib/Services/User/UserServerInterface.ts new file mode 100644 index 000000000..031d26395 --- /dev/null +++ b/packages/snjs/lib/Services/User/UserServerInterface.ts @@ -0,0 +1,5 @@ +import { HttpResponse, MinimalHttpResponse } from '@standardnotes/responses' + +export interface UserServerInterface { + deleteAccount(userUuid: string): Promise +} diff --git a/packages/snjs/lib/Services/User/UserService.ts b/packages/snjs/lib/Services/User/UserService.ts new file mode 100644 index 000000000..9cca8bd6a --- /dev/null +++ b/packages/snjs/lib/Services/User/UserService.ts @@ -0,0 +1,586 @@ +import { Challenge } from '../Challenge' +import { ChallengeService } from '../Challenge/ChallengeService' +import { EncryptionService, SNRootKey, SNRootKeyParams } from '@standardnotes/encryption' +import { HttpResponse, SignInResponse, User } from '@standardnotes/responses' +import { ItemManager } from '@Lib/Services/Items/ItemManager' +import { KeyParamsOrigination } from '@standardnotes/common' +import { + AbstractService, + AlertService, + ChallengePrompt, + ChallengeReason, + ChallengeValidation, + DeinitSource, + InternalEventBusInterface, + UserClientInterface, + StoragePersistencePolicies, +} from '@standardnotes/services' +import { SNApiService } from './../Api/ApiService' +import { SNProtectionService } from '../Protection/ProtectionService' +import { SNSessionManager, MINIMUM_PASSWORD_LENGTH } from '../Session/SessionManager' +import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService' +import { SNSyncService } from '../Sync/SyncService' +import { Strings } from '../../Strings/index' +import { UuidGenerator } from '@standardnotes/utils' +import * as Messages from '../Api/Messages' +import { UserRegistrationResponseBody } from '@standardnotes/api' + +const MINIMUM_PASSCODE_LENGTH = 1 + +export type CredentialsChangeFunctionResponse = { error?: { message: string } } +export type AccountServiceResponse = HttpResponse + +export enum AccountEvent { + SignedInOrRegistered = 'SignedInOrRegistered', + SignedOut = 'SignedOut', +} + +type AccountEventData = { + source: DeinitSource +} + +export class UserService extends AbstractService implements UserClientInterface { + private signingIn = false + private registering = false + + constructor( + private sessionManager: SNSessionManager, + private syncService: SNSyncService, + private storageService: DiskStorageService, + private itemManager: ItemManager, + private protocolService: EncryptionService, + private alertService: AlertService, + private challengeService: ChallengeService, + private protectionService: SNProtectionService, + private apiService: SNApiService, + protected override internalEventBus: InternalEventBusInterface, + ) { + super(internalEventBus) + } + + public override deinit(): void { + super.deinit() + ;(this.sessionManager as unknown) = undefined + ;(this.syncService as unknown) = undefined + ;(this.storageService as unknown) = undefined + ;(this.itemManager as unknown) = undefined + ;(this.protocolService as unknown) = undefined + ;(this.alertService as unknown) = undefined + ;(this.challengeService as unknown) = undefined + ;(this.protectionService as unknown) = undefined + ;(this.apiService as unknown) = undefined + } + + /** + * @param mergeLocal Whether to merge existing offline data into account. If false, + * any pre-existing data will be fully deleted upon success. + */ + public async register( + email: string, + password: string, + ephemeral = false, + mergeLocal = true, + ): Promise { + if (this.protocolService.hasAccount()) { + throw Error('Tried to register when an account already exists.') + } + + if (this.registering) { + throw Error('Already registering.') + } + + this.registering = true + + try { + this.lockSyncing() + const response = await this.sessionManager.register(email, password, ephemeral) + + this.syncService.resetSyncState() + + await this.storageService.setPersistencePolicy( + ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default, + ) + + if (mergeLocal) { + await this.syncService.markAllItemsAsNeedingSyncAndPersist() + } else { + await this.itemManager.removeAllItemsFromMemory() + await this.clearDatabase() + } + + await this.notifyEvent(AccountEvent.SignedInOrRegistered) + + this.unlockSyncing() + this.registering = false + + await this.syncService.downloadFirstSync(300) + void this.protocolService.decryptErroredPayloads() + + return response + } catch (error) { + this.unlockSyncing() + this.registering = false + + throw error + } + } + + /** + * @param mergeLocal Whether to merge existing offline data into account. + * If false, any pre-existing data will be fully deleted upon success. + */ + public async signIn( + email: string, + password: string, + strict = false, + ephemeral = false, + mergeLocal = true, + awaitSync = false, + ): Promise { + if (this.protocolService.hasAccount()) { + throw Error('Tried to sign in when an account already exists.') + } + + if (this.signingIn) { + throw Error('Already signing in.') + } + + this.signingIn = true + + try { + /** Prevent a timed sync from occuring while signing in. */ + this.lockSyncing() + + const result = await this.sessionManager.signIn(email, password, strict, ephemeral) + + if (!result.response.error) { + this.syncService.resetSyncState() + + await this.storageService.setPersistencePolicy( + ephemeral ? StoragePersistencePolicies.Ephemeral : StoragePersistencePolicies.Default, + ) + + if (mergeLocal) { + await this.syncService.markAllItemsAsNeedingSyncAndPersist() + } else { + void this.itemManager.removeAllItemsFromMemory() + await this.clearDatabase() + } + + await this.notifyEvent(AccountEvent.SignedInOrRegistered) + + this.unlockSyncing() + + const syncPromise = this.syncService + .downloadFirstSync(1_000, { + checkIntegrity: true, + awaitAll: awaitSync, + }) + .then(() => { + if (!awaitSync) { + void this.protocolService.decryptErroredPayloads() + } + }) + + if (awaitSync) { + await syncPromise + + await this.protocolService.decryptErroredPayloads() + } + } else { + this.unlockSyncing() + } + + return result.response + } finally { + this.signingIn = false + } + } + + public async deleteAccount(): Promise<{ + error: boolean + message?: string + }> { + if ( + !(await this.protectionService.authorizeAction(ChallengeReason.DeleteAccount, { + requireAccountPassword: true, + })) + ) { + return { + error: true, + message: Messages.INVALID_PASSWORD, + } + } + + const uuid = this.sessionManager.getSureUser().uuid + const response = await this.apiService.deleteAccount(uuid) + if (response.error) { + return { + error: true, + message: response.error.message, + } + } + + await this.signOut(true) + + void this.alertService.alert(Strings.Info.AccountDeleted) + + return { + error: false, + } + } + + /** + * A sign in request that occurs while the user was previously signed in, to correct + * for missing keys or storage values. Unlike regular sign in, this doesn't worry about + * performing one of marking all items as needing sync or deleting all local data. + */ + public async correctiveSignIn(rootKey: SNRootKey): Promise { + this.lockSyncing() + + const response = await this.sessionManager.bypassChecksAndSignInWithRootKey(rootKey.keyParams.identifier, rootKey) + + if (!response.error) { + await this.notifyEvent(AccountEvent.SignedInOrRegistered) + + this.unlockSyncing() + + void this.syncService.downloadFirstSync(1_000, { + checkIntegrity: true, + }) + + void this.protocolService.decryptErroredPayloads() + } + + this.unlockSyncing() + + return response + } + + /** + * @param passcode - Changing the account password or email requires the local + * passcode if configured (to rewrap the account key with passcode). If the passcode + * is not passed in, the user will be prompted for the passcode. However if the consumer + * already has reference to the passcode, they can pass it in here so that the user + * is not prompted again. + */ + public async changeCredentials(parameters: { + currentPassword: string + origination: KeyParamsOrigination + validateNewPasswordStrength: boolean + newEmail?: string + newPassword?: string + passcode?: string + }): Promise { + const result = await this.performCredentialsChange(parameters) + if (result.error) { + void this.alertService.alert(result.error.message) + } + return result + } + + public async signOut(force = false, source = DeinitSource.SignOut): Promise { + const performSignOut = async () => { + await this.sessionManager.signOut() + await this.protocolService.deleteWorkspaceSpecificKeyStateFromDevice() + await this.storageService.clearAllData() + await this.notifyEvent(AccountEvent.SignedOut, { source }) + } + + if (force) { + await performSignOut() + + return + } + + const dirtyItems = this.itemManager.getDirtyItems() + if (dirtyItems.length > 0) { + const singular = dirtyItems.length === 1 + const didConfirm = await this.alertService.confirm( + `There ${singular ? 'is' : 'are'} ${dirtyItems.length} ${ + singular ? 'item' : 'items' + } with unsynced changes. If you sign out, these changes will be lost forever. Are you sure you want to sign out?`, + ) + if (didConfirm) { + await performSignOut() + } + } else { + await performSignOut() + } + } + + public async performProtocolUpgrade(): Promise<{ + success?: true + canceled?: true + error?: { message: string } + }> { + const hasPasscode = this.protocolService.hasPasscode() + const hasAccount = this.protocolService.hasAccount() + const prompts = [] + if (hasPasscode) { + prompts.push( + new ChallengePrompt( + ChallengeValidation.LocalPasscode, + undefined, + Messages.ChallengeStrings.LocalPasscodePlaceholder, + ), + ) + } + if (hasAccount) { + prompts.push( + new ChallengePrompt( + ChallengeValidation.AccountPassword, + undefined, + Messages.ChallengeStrings.AccountPasswordPlaceholder, + ), + ) + } + const challenge = new Challenge(prompts, ChallengeReason.ProtocolUpgrade, true) + const response = await this.challengeService.promptForChallengeResponse(challenge) + if (!response) { + return { canceled: true } + } + const dismissBlockingDialog = await this.alertService.blockingDialog( + Messages.DO_NOT_CLOSE_APPLICATION, + Messages.UPGRADING_ENCRYPTION, + ) + try { + let passcode: string | undefined + if (hasPasscode) { + /* Upgrade passcode version */ + const value = response.getValueForType(ChallengeValidation.LocalPasscode) + passcode = value.value as string + } + if (hasAccount) { + /* Upgrade account version */ + const value = response.getValueForType(ChallengeValidation.AccountPassword) + const password = value.value as string + const changeResponse = await this.changeCredentials({ + currentPassword: password, + newPassword: password, + passcode, + origination: KeyParamsOrigination.ProtocolUpgrade, + validateNewPasswordStrength: false, + }) + if (changeResponse?.error) { + return { error: changeResponse.error } + } + } + if (hasPasscode) { + /* Upgrade passcode version */ + await this.removePasscodeWithoutWarning() + await this.setPasscodeWithoutWarning(passcode as string, KeyParamsOrigination.ProtocolUpgrade) + } + return { success: true } + } catch (error) { + return { error: error as Error } + } finally { + dismissBlockingDialog() + } + } + + public async addPasscode(passcode: string): Promise { + if (passcode.length < MINIMUM_PASSCODE_LENGTH) { + return false + } + if (!(await this.protectionService.authorizeAddingPasscode())) { + return false + } + + const dismissBlockingDialog = await this.alertService.blockingDialog( + Messages.DO_NOT_CLOSE_APPLICATION, + Messages.SETTING_PASSCODE, + ) + try { + await this.setPasscodeWithoutWarning(passcode, KeyParamsOrigination.PasscodeCreate) + return true + } finally { + dismissBlockingDialog() + } + } + + public async removePasscode(): Promise { + if (!(await this.protectionService.authorizeRemovingPasscode())) { + return false + } + + const dismissBlockingDialog = await this.alertService.blockingDialog( + Messages.DO_NOT_CLOSE_APPLICATION, + Messages.REMOVING_PASSCODE, + ) + try { + await this.removePasscodeWithoutWarning() + return true + } finally { + dismissBlockingDialog() + } + } + + /** + * @returns whether the passcode was successfuly changed or not + */ + public async changePasscode( + newPasscode: string, + origination = KeyParamsOrigination.PasscodeChange, + ): Promise { + if (newPasscode.length < MINIMUM_PASSCODE_LENGTH) { + return false + } + if (!(await this.protectionService.authorizeChangingPasscode())) { + return false + } + + const dismissBlockingDialog = await this.alertService.blockingDialog( + Messages.DO_NOT_CLOSE_APPLICATION, + origination === KeyParamsOrigination.ProtocolUpgrade + ? Messages.ProtocolUpgradeStrings.UpgradingPasscode + : Messages.CHANGING_PASSCODE, + ) + try { + await this.removePasscodeWithoutWarning() + await this.setPasscodeWithoutWarning(newPasscode, origination) + return true + } finally { + dismissBlockingDialog() + } + } + + private async setPasscodeWithoutWarning(passcode: string, origination: KeyParamsOrigination) { + const identifier = UuidGenerator.GenerateUuid() + const key = await this.protocolService.createRootKey(identifier, passcode, origination) + await this.protocolService.setNewRootKeyWrapper(key) + await this.rewriteItemsKeys() + await this.syncService.sync() + } + + private async removePasscodeWithoutWarning() { + await this.protocolService.removePasscode() + await this.rewriteItemsKeys() + } + + /** + * Allows items keys to be rewritten to local db on local credential status change, + * such as if passcode is added, changed, or removed. + * This allows IndexedDB unencrypted logs to be deleted + * `deletePayloads` will remove data from backing store, + * but not from working memory See: + * https://github.com/standardnotes/desktop/issues/131 + */ + private async rewriteItemsKeys(): Promise { + const itemsKeys = this.itemManager.getDisplayableItemsKeys() + const payloads = itemsKeys.map((key) => key.payloadRepresentation()) + await this.storageService.forceDeletePayloads(payloads) + await this.syncService.persistPayloads(payloads) + } + + private lockSyncing(): void { + this.syncService.lockSyncing() + } + + private unlockSyncing(): void { + this.syncService.unlockSyncing() + } + + private clearDatabase(): Promise { + return this.storageService.clearAllPayloads() + } + + private async performCredentialsChange(parameters: { + currentPassword: string + origination: KeyParamsOrigination + validateNewPasswordStrength: boolean + newEmail?: string + newPassword?: string + passcode?: string + }): Promise { + const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable(parameters.passcode) + + if (canceled) { + return { error: Error(Messages.CredentialsChangeStrings.PasscodeRequired) } + } + + if (parameters.newPassword !== undefined && parameters.validateNewPasswordStrength) { + if (parameters.newPassword.length < MINIMUM_PASSWORD_LENGTH) { + return { + error: Error(Messages.InsufficientPasswordMessage(MINIMUM_PASSWORD_LENGTH)), + } + } + } + + const accountPasswordValidation = await this.protocolService.validateAccountPassword(parameters.currentPassword) + if (!accountPasswordValidation.valid) { + return { + error: Error(Messages.INVALID_PASSWORD), + } + } + + const user = this.sessionManager.getUser() as User + const currentEmail = user.email + const rootKeys = await this.recomputeRootKeysForCredentialChange({ + currentPassword: parameters.currentPassword, + currentEmail, + origination: parameters.origination, + newEmail: parameters.newEmail, + newPassword: parameters.newPassword, + }) + + this.lockSyncing() + + /** Now, change the credentials on the server. Roll back on failure */ + const result = await this.sessionManager.changeCredentials({ + currentServerPassword: rootKeys.currentRootKey.serverPassword as string, + newRootKey: rootKeys.newRootKey, + wrappingKey, + newEmail: parameters.newEmail, + }) + + this.unlockSyncing() + + if (!result.response.error) { + const rollback = await this.protocolService.createNewItemsKeyWithRollback() + await this.protocolService.reencryptItemsKeys() + await this.syncService.sync({ awaitAll: true }) + + const defaultItemsKey = this.protocolService.getSureDefaultItemsKey() + const itemsKeyWasSynced = !defaultItemsKey.neverSynced + + if (!itemsKeyWasSynced) { + await this.sessionManager.changeCredentials({ + currentServerPassword: rootKeys.newRootKey.serverPassword as string, + newRootKey: rootKeys.currentRootKey, + wrappingKey, + }) + await this.protocolService.reencryptItemsKeys() + await rollback() + await this.syncService.sync({ awaitAll: true }) + + return { error: Error(Messages.CredentialsChangeStrings.Failed) } + } + } + + return result.response + } + + private async recomputeRootKeysForCredentialChange(parameters: { + currentPassword: string + currentEmail: string + origination: KeyParamsOrigination + newEmail?: string + newPassword?: string + }): Promise<{ currentRootKey: SNRootKey; newRootKey: SNRootKey }> { + const currentRootKey = await this.protocolService.computeRootKey( + parameters.currentPassword, + (await this.protocolService.getRootKeyParams()) as SNRootKeyParams, + ) + const newRootKey = await this.protocolService.createRootKey( + parameters.newEmail ?? parameters.currentEmail, + parameters.newPassword ?? parameters.currentPassword, + parameters.origination, + ) + + return { + currentRootKey, + newRootKey, + } + } +} diff --git a/packages/snjs/lib/Services/User/index.ts b/packages/snjs/lib/Services/User/index.ts new file mode 100644 index 000000000..384a674f0 --- /dev/null +++ b/packages/snjs/lib/Services/User/index.ts @@ -0,0 +1,2 @@ +export * from './UserServerInterface' +export * from './UserService' diff --git a/packages/snjs/lib/Services/index.ts b/packages/snjs/lib/Services/index.ts new file mode 100644 index 000000000..8a6257f67 --- /dev/null +++ b/packages/snjs/lib/Services/index.ts @@ -0,0 +1,22 @@ +export * from './Actions/ActionsService' +export * from './Api' +export * from './AppService/ApplicationService' +export * from './Challenge' +export * from './ComponentManager' +export * from './Features' +export * from './History' +export * from './Items' +export * from './KeyRecovery/KeyRecoveryService' +export * from './Listed' +export * from './Mfa/MfaService' +export * from './Migration/MigrationService' +export * from './Mutator' +export * from './Payloads' +export * from './Preferences/PreferencesService' +export * from './Protection' +export * from './Session' +export * from './Settings' +export * from './Singleton/SingletonManager' +export * from './Storage/DiskStorageService' +export * from './Sync' +export * from './User' diff --git a/packages/snjs/lib/Strings/Confirm.ts b/packages/snjs/lib/Strings/Confirm.ts new file mode 100644 index 000000000..b99ad80d1 --- /dev/null +++ b/packages/snjs/lib/Strings/Confirm.ts @@ -0,0 +1,18 @@ +import { ProtocolExpirationDates, ProtocolVersion } from '@standardnotes/common' + +export const ConfirmStrings = { + ProtocolVersionExpired(version: ProtocolVersion) { + return { + Message: + 'The encryption version for your account is outdated and requires upgrade. ' + + 'You may proceed with login, but are advised to perform a security update using the web or desktop application.\n\n' + + `If your account was created after ${ProtocolExpirationDates[ + version + ]?.toLocaleString()}, it may not be safe to continue signing in. ` + + 'In that case, please discontinue your sign in request and contact support.\n\n' + + 'For more information, visit standardnotes.com/help/security.', + Title: 'Update Recommended', + ConfirmButton: 'Sign In', + } + }, +} diff --git a/packages/snjs/lib/Strings/Info.ts b/packages/snjs/lib/Strings/Info.ts new file mode 100644 index 000000000..310d58155 --- /dev/null +++ b/packages/snjs/lib/Strings/Info.ts @@ -0,0 +1,7 @@ +export const InfoStrings = { + AccountDeleted: 'Your account has been successfully deleted.', + UnsupportedBackupFileVersion: + 'This backup file was created using a newer version of the application and cannot be imported here. Please update your application and try again.', + BackupFileMoreRecentThanAccount: + "This backup file was created using a newer encryption version than your account's. Please run the available encryption upgrade and try again.", +} diff --git a/packages/snjs/lib/Strings/Input.ts b/packages/snjs/lib/Strings/Input.ts new file mode 100644 index 000000000..d1f1082de --- /dev/null +++ b/packages/snjs/lib/Strings/Input.ts @@ -0,0 +1,3 @@ +export const InputStrings = { + FileAccountPassword: 'File account password', +} diff --git a/packages/snjs/lib/Strings/Network.ts b/packages/snjs/lib/Strings/Network.ts new file mode 100644 index 000000000..5000bcce1 --- /dev/null +++ b/packages/snjs/lib/Strings/Network.ts @@ -0,0 +1,9 @@ +export const NetworkStrings = { + Files: { + FailedStartUploadSession: 'Failed to start an upload session.', + FailedCloseUploadSession: 'Failed to close an upload session.', + FailedUploadFileChunk: 'Failed to upload file chunk.', + FailedDownloadFileChunk: 'Failed to download file chunk.', + FailedDeleteFile: 'Failed to delete file.', + }, +} diff --git a/packages/snjs/lib/Strings/index.ts b/packages/snjs/lib/Strings/index.ts new file mode 100644 index 000000000..268e89c41 --- /dev/null +++ b/packages/snjs/lib/Strings/index.ts @@ -0,0 +1,11 @@ +import { ConfirmStrings } from './Confirm' +import { InfoStrings } from './Info' +import { InputStrings } from './Input' +import { NetworkStrings } from './Network' + +export const Strings = { + Info: InfoStrings, + Network: NetworkStrings, + Confirm: ConfirmStrings, + Input: InputStrings, +} diff --git a/packages/snjs/lib/Types/ApplicationEventPayload.ts b/packages/snjs/lib/Types/ApplicationEventPayload.ts new file mode 100644 index 000000000..a8cff0a4c --- /dev/null +++ b/packages/snjs/lib/Types/ApplicationEventPayload.ts @@ -0,0 +1 @@ +export type ApplicationEventPayload = Partial> diff --git a/packages/snjs/lib/Types/IconType.ts b/packages/snjs/lib/Types/IconType.ts new file mode 100644 index 000000000..260076c90 --- /dev/null +++ b/packages/snjs/lib/Types/IconType.ts @@ -0,0 +1,183 @@ +export type IconType = + | 'accessibility' + | 'account-card-details-outline' + | 'account-circle' + | 'add-bold' + | 'add-text' + | 'add' + | 'archive' + | 'arrow-down' + | 'arrow-left' + | 'arrow-right' + | 'arrow-up' + | 'arrows-horizontal' + | 'arrows-sort-down' + | 'arrows-sort-up' + | 'asterisk' + | 'attachment-file' + | 'authenticator-variant' + | 'authenticator' + | 'back-ios' + | 'bold' + | 'box-filled' + | 'box' + | 'camera' + | 'check-all' + | 'check-bold' + | 'check-circle-filled' + | 'check-circle' + | 'check' + | 'chevron-down' + | 'chevron-left' + | 'chevron-right' + | 'chevron-up' + | 'clear-circle-filled' + | 'close' + | 'cloud-off' + | 'code-2' + | 'code-tags' + | 'code' + | 'color-fill' + | 'copy' + | 'dashboard' + | 'download' + | 'drag' + | 'draw' + | 'editor-filled' + | 'editor' + | 'email-filled' + | 'email' + | 'enter' + | 'eye-filled' + | 'eye-off-filled' + | 'eye-off' + | 'eye' + | 'feedback' + | 'file-filled' + | 'file' + | 'file-other' + | 'file-pdf' + | 'file-doc' + | 'file-ppt' + | 'file-xls' + | 'file-image' + | 'file-mov' + | 'file-music' + | 'file-zip' + | 'files-illustration' + | 'folder-filled' + | 'folder-key-filled' + | 'folder' + | 'format-align-center' + | 'format-align-justify' + | 'format-align-left' + | 'format-align-right' + | 'forward-ios' + | 'fullscreen-exit' + | 'fullscreen' + | 'gift-outline' + | 'hashtag-filled' + | 'hashtag-off' + | 'hashtag' + | 'heart-filled' + | 'help-filled' + | 'help' + | 'history' + | 'image' + | 'info' + | 'italic' + | 'keyboard-close' + | 'keyboard-command' + | 'keyboard-filled' + | 'keyboard-option' + | 'keyboard-shift' + | 'keyboard-show' + | 'keyboard' + | 'lifebuoy' + | 'line-width' + | 'link-off' + | 'link' + | 'list-bulleted' + | 'listed-filled' + | 'listed' + | 'lock-filled' + | 'lock' + | 'markdown' + | 'menu-arrow-down-alt' + | 'menu-arrow-down' + | 'menu-arrow-right' + | 'menu-close' + | 'menu-open' + | 'menu-variant' + | 'merge' + | 'more-vert' + | 'more' + | 'notes-filled' + | 'notes-remove' + | 'notes' + | 'open-in' + | 'password' + | 'pencil-filled' + | 'pencil-off' + | 'pencil' + | 'pin-filled' + | 'pin-off' + | 'pin' + | 'plain-text' + | 'plus-circle-filled' + | 'plus-circle' + | 'premium-feature' + | 'print' + | 'redo' + | 'restore' + | 'rich-text' + | 'safe-square-filled' + | 'safe-square' + | 'safe' + | 'save' + | 'search-ios' + | 'search' + | 'security' + | 'select-all' + | 'send' + | 'server' + | 'settings-filled' + | 'settings' + | 'share' + | 'signIn' + | 'signOut' + | 'sort-descending' + | 'spellcheck' + | 'spreadsheets' + | 'standard-notes-2' + | 'standard-notes' + | 'star-filled' + | 'star-variant-filled' + | 'star' + | 'strikethrough' + | 'sync' + | 'tasks' + | 'text-circle' + | 'text-paragraph-long' + | 'text' + | 'textbox-password' + | 'themes-filled' + | 'themes' + | 'timer' + | 'trash-filled' + | 'trash-sweep-filled' + | 'trash-sweep' + | 'trash' + | 'tune' + | 'unarchive' + | 'underline' + | 'undo' + | 'unpin' + | 'upload' + | 'user-add' + | 'user-filled' + | 'user-switch' + | 'user' + | 'view' + | 'warning' + | 'window' diff --git a/packages/snjs/lib/Types/UuidString.ts b/packages/snjs/lib/Types/UuidString.ts new file mode 100644 index 000000000..798d44a32 --- /dev/null +++ b/packages/snjs/lib/Types/UuidString.ts @@ -0,0 +1 @@ +export type UuidString = string diff --git a/packages/snjs/lib/Types/index.ts b/packages/snjs/lib/Types/index.ts new file mode 100644 index 000000000..3dc5e03b8 --- /dev/null +++ b/packages/snjs/lib/Types/index.ts @@ -0,0 +1,3 @@ +export * from './ApplicationEventPayload' +export * from './IconType' +export * from './UuidString' diff --git a/packages/snjs/lib/Version.spec.ts b/packages/snjs/lib/Version.spec.ts new file mode 100644 index 000000000..bc3c0107c --- /dev/null +++ b/packages/snjs/lib/Version.spec.ts @@ -0,0 +1,30 @@ +import { compareSemVersions, isRightVersionGreaterThanLeft } from './Version' + +describe('versions', () => { + it('isRightVersionGreaterThanLeft', () => { + expect(isRightVersionGreaterThanLeft('0.0.0', '0.0.1')).toEqual(true) + expect(isRightVersionGreaterThanLeft('1.0.0', '1.0.1')).toEqual(true) + + expect(isRightVersionGreaterThanLeft('0.0.1', '0.0.0')).toEqual(false) + expect(isRightVersionGreaterThanLeft('0.1.1', '0.1.0')).toEqual(false) + expect(isRightVersionGreaterThanLeft('1.1.1', '1.1.0')).toEqual(false) + + expect(isRightVersionGreaterThanLeft('1.0.0', '1.0.1-beta.1')).toEqual(true) + expect(isRightVersionGreaterThanLeft('1.0.0', '1.0.1-alpha.1')).toEqual(true) + expect(isRightVersionGreaterThanLeft('1.4.2', '1.4.3-alpha.1')).toEqual(true) + }) + + it('compareSemVersions', () => { + expect(compareSemVersions('1.0.0', '1.0.1')).toEqual(-1) + expect(compareSemVersions('1.0.0', '1.0.0')).toEqual(0) + expect(compareSemVersions('1.0.1', '1.0.0')).toEqual(1) + expect(compareSemVersions('100.0.1', '2.0.15')).toEqual(1) + + expect(compareSemVersions('2.0.1001', '2.0.1')).toEqual(1) + expect(compareSemVersions('2.0.1001', '2.2.1')).toEqual(-1) + + expect(compareSemVersions('1.0.1-beta.1', '1.0.1-beta.1')).toEqual(0) + expect(compareSemVersions('1.0.1-alpha.1', '1.0.1-alpha.1')).toEqual(0) + expect(compareSemVersions('1.0.1-alpha.1', '1.0.1-alpha.2')).toEqual(-1) + }) +}) diff --git a/packages/snjs/lib/Version.ts b/packages/snjs/lib/Version.ts new file mode 100644 index 000000000..9e9d3ed58 --- /dev/null +++ b/packages/snjs/lib/Version.ts @@ -0,0 +1,40 @@ +import { eq, gt } from 'semver' + +/** Declared in webpack config */ +declare const __VERSION__: string +export const SnjsVersion = __VERSION__ + +/** + * Legacy architecture (pre-3.5 clients) + */ +export const PreviousSnjsVersion1_0_0 = '1.0.0' + +/** + * First release of new architecture, did not automatically store version + */ +export const PreviousSnjsVersion2_0_0 = '2.0.0' + +/** + * Returns true if the version string on the right is greater than the one + * on the left. Accepts any format version number, like 2, 2.0, 2.0.0, or even 2.0.0.01 + */ +export function isRightVersionGreaterThanLeft(left: string, right: string): boolean { + return compareSemVersions(left, right) === -1 +} + +/** + * -1 if a < b + * 0 if a == b + * 1 if a > b + */ +export function compareSemVersions(left: string, right: string): 1 | -1 | 0 { + if (eq(left, right)) { + return 0 + } + + if (gt(left, right)) { + return 1 + } + + return -1 +} diff --git a/packages/snjs/lib/index.ts b/packages/snjs/lib/index.ts new file mode 100644 index 000000000..f8eadd30a --- /dev/null +++ b/packages/snjs/lib/index.ts @@ -0,0 +1,16 @@ +export * from './Application' +export * from './ApplicationGroup' +export * from './Client' +export * from './Log' +export * from './Migrations' +export * from './Services' +export * from './Types' +export * from './Version' +export * from '@standardnotes/common' +export * from '@standardnotes/encryption' +export * from '@standardnotes/features' +export * from '@standardnotes/models' +export * from '@standardnotes/responses' +export * from '@standardnotes/services' +export * from '@standardnotes/settings' +export * from '@standardnotes/utils' diff --git a/packages/snjs/lib/tsconfig.json b/packages/snjs/lib/tsconfig.json new file mode 100644 index 000000000..5940d3fb3 --- /dev/null +++ b/packages/snjs/lib/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "allowJs": true, + "alwaysStrict": true, + "baseUrl": ".", + "declaration": true, + "declarationDir": "../dist/@types", + "emitDeclarationOnly": true, + "esModuleInterop": true, + "isolatedModules": true, + "moduleResolution": "node", + "newLine": "lf", + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "outDir": "../dist/@types", + "strict": true, + "strictNullChecks": true, + "target": "esnext", + "paths": { + "@Lib/*": ["*"], + "@Services/*": ["Services/*"] + } + }, + "exclude": ["../package.json"] +} diff --git a/packages/snjs/linter.tsconfig.json b/packages/snjs/linter.tsconfig.json new file mode 100644 index 000000000..c1a7d22c5 --- /dev/null +++ b/packages/snjs/linter.tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["dist"] +} diff --git a/packages/snjs/mocha/.eslintrc.js b/packages/snjs/mocha/.eslintrc.js new file mode 100644 index 000000000..00f68f3b4 --- /dev/null +++ b/packages/snjs/mocha/.eslintrc.js @@ -0,0 +1,15 @@ +module.exports = { + extends: ['../.eslintrc.js'], + globals: { + chai: true, + chaiAsPromised: true, + describe: true, + beforeEach: true, + localStorage: true, + it: true, + afterEach: true, + ContentType: true, + fetch: true, + ClientDisplayableError: true, + }, +} diff --git a/packages/snjs/mocha/000.test.js b/packages/snjs/mocha/000.test.js new file mode 100644 index 000000000..5f9b984c4 --- /dev/null +++ b/packages/snjs/mocha/000.test.js @@ -0,0 +1,36 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('000 legacy protocol operations', () => { + const application = Factory.createApplicationWithRealCrypto() + const protocol004 = new SNProtocolOperator004(new SNWebCrypto()) + + before(async () => { + await Factory.initializeApplication(application) + }) + + after(async () => { + await Factory.safeDeinit(application) + }) + + it('cannot decode 000 item', function () { + const string = + '000eyJyZWZlcmVuY2VzIjpbeyJ1dWlkIjoiZGMwMDUwZWUtNWQyNi00MGMyLWJjMjAtYzU1ZWE1Yjc4MmUwIiwiY29udGVudF90eXBlIjoiU058VXNlclByZWZlcmVuY2VzIn1dLCJhcHBEYXRhIjp7Im9yZy5zdGFuZGFyZG5vdGVzLnNuIjp7ImNsaWVudF91cGRhdGVkX2F0IjoiMjAyMC0wNC0wOFQxNDoxODozNC4yNzBaIn19LCJ0aXRsZSI6IjAuMDMyMzc3OTQyMDUxNzUzMzciLCJ0ZXh0Ijoid29ybGQifQ==' + + let error + try { + protocol004.generateDecryptedParametersSync({ + uuid: 'foo', + content: string, + content_type: 'foo', + }) + } catch (e) { + error = e + } + + expect(error).to.be.ok + }) +}) diff --git a/packages/snjs/mocha/001.test.js b/packages/snjs/mocha/001.test.js new file mode 100644 index 000000000..951c2e68f --- /dev/null +++ b/packages/snjs/mocha/001.test.js @@ -0,0 +1,124 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('001 protocol operations', () => { + const application = Factory.createApplicationWithRealCrypto() + const protocol001 = new SNProtocolOperator001(new SNWebCrypto()) + + const _identifier = 'hello@test.com' + const _password = 'password' + let _keyParams, _key + + // runs once before all tests in this block + before(async () => { + localStorage.clear() + await Factory.initializeApplication(application) + _key = await protocol001.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + _keyParams = _key.keyParams + }) + + after(async () => { + await Factory.safeDeinit(application) + }) + + it('generates random key', async () => { + const length = 128 + const key = await protocol001.crypto.generateRandomKey(length) + expect(key.length).to.equal(length / 4) + }) + + it('cost minimum', () => { + expect(application.protocolService.costMinimumForVersion('001')).to.equal(3000) + }) + + it('generates valid keys for registration', async () => { + const key = await protocol001.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + expect(key.serverPassword).to.be.ok + expect(key.masterKey).to.be.ok + + expect(key.keyParams.content.pw_nonce).to.be.ok + expect(key.keyParams.content.pw_cost).to.be.ok + expect(key.keyParams.content.pw_salt).to.be.ok + }) + + it('generates valid keys from existing params and decrypts', async () => { + const password = 'password' + const keyParams = await application.protocolService.createKeyParams({ + pw_func: 'pbkdf2', + pw_alg: 'sha512', + pw_key_size: 512, + pw_cost: 5000, + pw_salt: '45cf889386d7ed72a0dcfb9d06fee9f6274ec0ce', + }) + const key = await protocol001.computeRootKey(password, keyParams) + expect(key.keyVersion).to.equal('001') + expect(key.serverPassword).to.equal('8f2f0513e90648c08ef6fa55eda00bb76e82dfdc2e218e4338b6246e0f68eb78') + expect(key.masterKey).to.equal('65e040f8ef6775fecbb7ee5599ec3f059faa96d728e50f2014237a802ac5bd0f') + expect(key.dataAuthenticationKey).to.not.be.ok + const payload = new EncryptedPayload({ + auth_hash: '0ae7e3c9fce61f07a8d5d267accab20793a06ab266c245fe59178d49c1ad3fa6', + content: + '001hEIgw837WzFM7Eb5tBHHXumxxKwaWuDv5hyhmrNDTUU5qxnb5jkjo1HsRzw+Z65BMuDqIdHlZU3plW+4QpJ6iFksFPYgo8VHa++dOtfAP7Q=', + content_type: 'Note', + enc_item_key: + 'sVuHmG0XAp1PRDE8r8XqFXijjP8Pqdwal9YFRrXK4hKLt1yyq8MwQU+1Z95Tz/b7ajYdidwFE0iDwd8Iu8281VtJsQ4yhh2tJiAzBy6newyHfhA5nH93yZ3iXRJaG87bgNQE9lsXzTV/OHAvqMuQtw/QVSWI3Qy1Pyu1Tn72q7FPKKhRRkzEEZ+Ax0BA1fHg', + uuid: '54001a6f-7c22-4b34-8316-fadf9b1fc255', + }) + const decrypted = await application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [payload], + key: key, + }, + }) + expect(decrypted.errorDecrypting).to.not.be.ok + expect(decrypted.content.text).to.equal('Decryptable Sentence') + }) + + it('properly encrypts and decrypts', async () => { + const text = 'hello world' + const key = _key.masterKey + const encString = await protocol001.encryptString(text, key) + const decString = await protocol001.decryptString(encString, key) + expect(decString).to.equal(text) + }) + + it('generates existing keys for key params', async () => { + const key = await protocol001.computeRootKey(_password, _keyParams) + expect(key.content).to.have.property('serverPassword') + expect(key.content).to.have.property('masterKey') + expect(key.compare(_key)).to.be.true + }) + + it('generating encryption params includes items_key_id', async () => { + const payload = Factory.createNotePayload() + const key = await protocol001.createItemsKey() + const params = await protocol001.generateEncryptedParametersAsync(payload, key) + expect(params.content).to.be.ok + expect(params.enc_item_key).to.be.ok + expect(params.auth_hash).to.be.ok + expect(params.items_key_id).to.equal(key.uuid) + }) + + it('can decrypt encrypted params', async () => { + const payload = Factory.createNotePayload() + const key = await protocol001.createItemsKey() + const params = await protocol001.generateEncryptedParametersAsync(payload, key) + const decrypted = await protocol001.generateDecryptedParametersAsync(params, key) + expect(decrypted.content).to.eql(payload.content) + }) + + it('payloads missing enc_item_key should decrypt as errorDecrypting', async () => { + const payload = Factory.createNotePayload() + const key = await protocol001.createItemsKey() + const params = await protocol001.generateEncryptedParametersAsync(payload, key) + const modified = new EncryptedPayload({ + ...params, + enc_item_key: undefined, + }) + const decrypted = await protocol001.generateDecryptedParametersAsync(modified, key) + expect(decrypted.errorDecrypting).to.equal(true) + }) +}) diff --git a/packages/snjs/mocha/002.test.js b/packages/snjs/mocha/002.test.js new file mode 100644 index 000000000..b4b616da8 --- /dev/null +++ b/packages/snjs/mocha/002.test.js @@ -0,0 +1,120 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('002 protocol operations', () => { + const _identifier = 'hello@test.com' + const _password = 'password' + let _keyParams, _key + const application = Factory.createApplicationWithRealCrypto() + const protocol002 = new SNProtocolOperator002(new SNWebCrypto()) + + // runs once before all tests in this block + before(async () => { + localStorage.clear() + await Factory.initializeApplication(application) + _key = await protocol002.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + _keyParams = _key.keyParams + }) + + after(async () => { + await Factory.safeDeinit(application) + }) + + it('generates random key', async () => { + const length = 128 + const key = await protocol002.crypto.generateRandomKey(length) + expect(key.length).to.equal(length / 4) + }) + + it('cost minimum', () => { + expect(application.protocolService.costMinimumForVersion('002')).to.equal(3000) + }) + + it('generates valid keys for registration', async () => { + const key = await protocol002.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + expect(key.dataAuthenticationKey).to.be.ok + expect(key.serverPassword).to.be.ok + expect(key.masterKey).to.be.ok + + expect(key.keyParams.content.pw_nonce).to.be.ok + expect(key.keyParams.content.pw_cost).to.be.ok + expect(key.keyParams.content.pw_salt).to.be.ok + }) + + it('generates valid keys from existing params and decrypts', async () => { + const password = 'password' + const keyParams = await application.protocolService.createKeyParams({ + pw_salt: '8d381ef44cdeab1489194f87066b747b46053a833ee24956e846e7b40440f5f4', + pw_cost: 101000, + version: '002', + }) + const key = await protocol002.computeRootKey(password, keyParams) + expect(key.keyVersion).to.equal('002') + expect(key.serverPassword).to.equal('f3cc7efc93380a7a3765dcb0498dabe83387acdda78f43bc7cfc31f4a2a05077') + expect(key.masterKey).to.equal('66500f7c9fb8ba0843e13e2f555feb5e43a3c27fee23e9b900a2577f1b373e1a') + expect(key.dataAuthenticationKey).to.equal('af3d6a7fd6c0422a7a84b0e99d6ac2a79b77675c9848f74314c20046e1f95c75') + const payload = new EncryptedPayload({ + content: + '002:0ff292a79549e817003886e9c4865eaf5faa0b3ada5b41c846c63bd4056e6816:959b042a-3892-461e-8c50-477c10c7c40a:c856f9d81033994f397285e2d060e9d4:pQ/jKyb8qCsz18jdMiYkpxf4l8ELIbTtwqUwLM3fRUwDL4/ofZLGICuFlssmrb74Brm+N19znwfNQ9ouFPtijA==', + content_type: 'Note', + enc_item_key: + '002:24a8e8f7728bbe06605d8209d87ad338d3d15ef81154bb64d3967c77daa01333:959b042a-3892-461e-8c50-477c10c7c40a:f1d294388742dca34f6f266a01483a4e:VdlEDyjhZ35GbJDg8ruSZv3Tp6WtMME3T5LLvcBYLHIMhrMi0RlPK83lK6F0aEaZvY82pZ0ntU+XpAX7JMSEdKdPXsACML7WeFrqKb3z2qHnA7NxgnIC0yVT/Z2mRrvlY3NNrUPGwJbfRcvfS7FVyw87MemT9CSubMZRviXvXETx82t7rsgjV/AIwOOeWhFi', + uuid: '959b042a-3892-461e-8c50-477c10c7c40a', + }) + const decrypted = await application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [payload], + key: key, + }, + }) + expect(decrypted.errorDecrypting).to.not.be.ok + expect(decrypted.content.text).to.equal('Decryptable Sentence') + }) + + it('properly encrypts and decrypts strings', async () => { + const text = 'hello world' + const key = _key.masterKey + const iv = await protocol002.crypto.generateRandomKey(128) + const encString = await protocol002.encryptString002(text, key, iv) + const decString = await protocol002.decryptString002(encString, key, iv) + expect(decString).to.equal(text) + }) + + it('generates existing keys for key params', async () => { + const key = await protocol002.computeRootKey(_password, _keyParams) + expect(key.compare(_key)).to.be.true + }) + + it('generating encryption params includes items_key_id', async () => { + const payload = Factory.createNotePayload() + const key = await protocol002.createItemsKey() + const params = await protocol002.generateEncryptedParametersAsync(payload, key) + expect(params.content).to.be.ok + expect(params.enc_item_key).to.be.ok + expect(params.items_key_id).to.equal(key.uuid) + }) + + it('can decrypt encrypted params', async () => { + const payload = Factory.createNotePayload() + const key = await protocol002.createItemsKey() + const params = await protocol002.generateEncryptedParametersAsync(payload, key) + + const decrypted = await protocol002.generateDecryptedParametersAsync(params, key) + expect(decrypted.content).to.eql(payload.content) + }) + + it('payloads missing enc_item_key should decrypt as errorDecrypting', async () => { + const payload = Factory.createNotePayload() + const key = await protocol002.createItemsKey() + const params = await protocol002.generateEncryptedParametersAsync(payload, key) + const modified = new EncryptedPayload({ + ...params, + enc_item_key: undefined, + }) + const decrypted = await protocol002.generateDecryptedParametersAsync(modified, key) + expect(decrypted.errorDecrypting).to.equal(true) + }) +}) diff --git a/packages/snjs/mocha/003.test.js b/packages/snjs/mocha/003.test.js new file mode 100644 index 000000000..0def8f523 --- /dev/null +++ b/packages/snjs/mocha/003.test.js @@ -0,0 +1,125 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('003 protocol operations', () => { + before(async () => { + localStorage.clear() + }) + + after(async () => { + localStorage.clear() + }) + + const _identifier = 'hello@test.com' + const _password = 'password' + let _keyParams, _key + + const sharedApplication = Factory.createApplicationWithRealCrypto() + const protocol003 = new SNProtocolOperator003(new SNWebCrypto()) + + // runs once before all tests in this block + before(async () => { + await Factory.initializeApplication(sharedApplication) + _key = await protocol003.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + _keyParams = _key.keyParams + }) + + after(async () => { + await Factory.safeDeinit(sharedApplication) + }) + + it('generates random key', async () => { + const length = 128 + const key = await protocol003.crypto.generateRandomKey(length) + expect(key.length).to.equal(length / 4) + }) + + it('cost minimum should throw', () => { + expect(() => { + sharedApplication.protocolService.costMinimumForVersion('003') + }).to.throw('Cost minimums only apply to versions <= 002') + }) + + it('generates valid keys for registration', async () => { + const key = await protocol003.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + + expect(key.dataAuthenticationKey).to.be.ok + expect(key.serverPassword).to.be.ok + expect(key.masterKey).to.be.ok + + expect(key.keyParams.content.pw_nonce).to.be.ok + expect(key.keyParams.content.pw_cost).to.not.be.ok + expect(key.keyParams.content.pw_salt).to.not.be.ok + expect(key.keyParams.content.identifier).to.be.ok + }) + + it('computes proper keys for sign in', async () => { + const identifier = 'foo@bar.com' + const password = 'very_secure' + const keyParams = sharedApplication.protocolService.createKeyParams({ + pw_nonce: 'baaec0131d677cf993381367eb082fe377cefe70118c1699cb9b38f0bc850e7b', + identifier: identifier, + version: '003', + }) + const key = await protocol003.computeRootKey(password, keyParams) + expect(key.serverPassword).to.equal('60fdae231049d81974c562e943ad472f0143daa87f43048d2ede2d199ea7be25') + expect(key.masterKey).to.equal('2b2162e5299f71f9fcd39789a01f6062f2779220e97a43d7895cf30da11186e9') + expect(key.dataAuthenticationKey).to.equal('24dfba6f42ffc07a5223440a28a574d463e99d8d4aeb68fe95f55aa8ed5fd39f') + }) + + it('can decrypt item generated with web version 3.3.6', async () => { + const identifier = 'demo@standardnotes.org' + const password = 'password' + const keyParams = sharedApplication.protocolService.createKeyParams({ + pw_nonce: '31107837b44d86179140b7c602a55d694243e2e9ced0c4c914ac21ad90215055', + identifier: identifier, + version: '003', + }) + const key = await protocol003.computeRootKey(password, keyParams) + const payload = new EncryptedPayload({ + uuid: '80488ade-933a-4570-8852-5282a094fafc', + content_type: 'Note', + enc_item_key: + '003:f385f1af03c6e16844ba685b0766a93f65c6e1813c56146376994188c40902ef:80488ade-933a-4570-8852-5282a094fafc:8af48228d965847a3fb7904e801f3958:xM1UKtXEpXytu0amQ405rpJ8KvTcNNjqNcZEWfhefZQo+25cZfNgFRniZuO2ysXyR4qWiLQlWb5pptQi1gSNakOmCNl7WiQgH7t7ia7gwz667i6nrrwVJ8vauXeyTzspr4J/NHa1LM/f8/MDxiHWVG7MvXkWqGT7qBCzcY1BXXQaMlf6g1VEDq+INPnzSZG/:eyJpZGVudGlmaWVyIjoiZGVtb0BzdGFuZGFyZG5vdGVzLm9yZyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiIzMTEwNzgzN2I0NGQ4NjE3OTE0MGI3YzYwMmE1NWQ2OTQyNDNlMmU5Y2VkMGM0YzkxNGFjMjFhZDkwMjE1MDU1IiwidmVyc2lvbiI6IjAwMyJ9', + content: + '003:ca505a223d3ef3ad5cd4e6f4e0d06a2bb34b8b032f60180165c37acd5a4718e3:80488ade-933a-4570-8852-5282a094fafc:bad25bb4ba935646148fe7f118c5f60d:g+eHtGG+M4ZdIevpx9xkK9mmFYo8/1JTlaDysM18nGrA3Oe3wvFTfG5PPvH50uY6PgBbWZPS+BNpsH/gVMH8T9LCreRLPVw5yRhunyva0pgsk/k4Dmi4PTsvvNqhA2F8X2LZTwuw7QlLkvOneX9cNmNDzVGmsedSWhEZXbD5jmb1Ev77Gq1kjqh2eFc7lPa/WBb52fs8FHKbO9HUGqXF49/JOunpvp76/bAydavGQ2n/abkGCoYvrtmyM1lqthBb8w60KidkC/Hm4cGAm8wNKyg58YUHCYPAlaUI0DxPGXu24Ur/6M7HdP/9puitJGUSlXA32DXABMd8DbUk6JPvJRKvQ/v4Dd3UR0h7Gdm/YME=:eyJpZGVudGlmaWVyIjoiZGVtb0BzdGFuZGFyZG5vdGVzLm9yZyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiIzMTEwNzgzN2I0NGQ4NjE3OTE0MGI3YzYwMmE1NWQ2OTQyNDNlMmU5Y2VkMGM0YzkxNGFjMjFhZDkwMjE1MDU1IiwidmVyc2lvbiI6IjAwMyJ9', + }) + const decrypted = await protocol003.generateDecryptedParametersAsync(payload, key) + expect(decrypted.content.title).to.equal('Secret key') + expect(decrypted.content.text).to.equal('TaW8uq4cZRCNf3e4L8c7xFhsJkJdt6') + }) + + it('properly encrypts and decrypts', async () => { + const text = 'hello world' + const rawKey = _key.masterKey + const iv = await protocol003.crypto.generateRandomKey(128) + const encString = await protocol003.encryptString002(text, rawKey, iv) + const decString = await protocol003.decryptString002(encString, rawKey, iv) + expect(decString).to.equal(text) + }) + + it('generates existing keys for key params', async () => { + const key = await protocol003.computeRootKey(_password, _keyParams) + expect(key.compare(_key)).to.be.true + }) + + it('generating encryption params includes items_key_id', async () => { + const payload = Factory.createNotePayload() + const key = await protocol003.createItemsKey() + const params = await protocol003.generateEncryptedParametersAsync(payload, key) + expect(params.content).to.be.ok + expect(params.enc_item_key).to.be.ok + expect(params.items_key_id).to.equal(key.uuid) + }) + + it('can decrypt encrypted params', async () => { + const payload = Factory.createNotePayload() + const key = await protocol003.createItemsKey() + const params = await protocol003.generateEncryptedParametersAsync(payload, key) + const decrypted = await protocol003.generateDecryptedParametersAsync(params, key) + expect(decrypted.content).to.eql(payload.content) + }) +}) diff --git a/packages/snjs/mocha/004.test.js b/packages/snjs/mocha/004.test.js new file mode 100644 index 000000000..1a99816df --- /dev/null +++ b/packages/snjs/mocha/004.test.js @@ -0,0 +1,121 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('004 protocol operations', function () { + const _identifier = 'hello@test.com' + const _password = 'password' + let _keyParams + let _key + + const application = Factory.createApplicationWithRealCrypto() + const protocol004 = new SNProtocolOperator004(new SNWebCrypto()) + + before(async function () { + await Factory.initializeApplication(application) + _key = await protocol004.createRootKey(_identifier, _password, KeyParamsOrigination.Registration) + _keyParams = _key.keyParams + }) + + after(async function () { + await Factory.safeDeinit(application) + }) + + it('cost minimum should throw', function () { + expect(function () { + application.protocolService.costMinimumForVersion('004') + }).to.throw('Cost minimums only apply to versions <= 002') + }) + + it('generates valid keys for registration', async function () { + const key = await application.protocolService.createRootKey( + _identifier, + _password, + KeyParamsOrigination.Registration, + ) + + expect(key.masterKey).to.be.ok + + expect(key.serverPassword).to.be.ok + expect(key.mk).to.not.be.ok + expect(key.dataAuthenticationKey).to.not.be.ok + + expect(key.keyParams.content004.pw_nonce).to.be.ok + expect(key.keyParams.content004.pw_cost).to.not.be.ok + expect(key.keyParams.content004.salt).to.not.be.ok + expect(key.keyParams.content004.identifier).to.be.ok + }) + + it('computes proper keys for sign in', async function () { + const identifier = 'foo@bar.com' + const password = 'very_secure' + const keyParams = application.protocolService.createKeyParams({ + pw_nonce: 'baaec0131d677cf993381367eb082fe377cefe70118c1699cb9b38f0bc850e7b', + identifier: identifier, + version: '004', + }) + const key = await protocol004.computeRootKey(password, keyParams) + expect(key.masterKey).to.equal('5d68e78b56d454e32e1f5dbf4c4e7cf25d74dc1efc942e7c9dfce572c1f3b943') + expect(key.serverPassword).to.equal('83707dfc837b3fe52b317be367d3ed8e14e903b2902760884fd0246a77c2299d') + expect(key.dataAuthenticationKey).to.not.be.ok + }) + + it('generates random key', async function () { + const length = 96 + const key = await application.protocolService.crypto.generateRandomKey(length) + expect(key.length).to.equal(length / 4) + }) + + it('properly encrypts and decrypts', async function () { + const text = 'hello world' + const rawKey = _key.masterKey + const nonce = await application.protocolService.crypto.generateRandomKey(192) + const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) + const authenticatedData = { foo: 'bar' } + const encString = await operator.encryptString004(text, rawKey, nonce, authenticatedData) + const decString = await operator.decryptString004( + encString, + rawKey, + nonce, + await operator.authenticatedDataToString(authenticatedData), + ) + expect(decString).to.equal(text) + }) + + it('fails to decrypt non-matching aad', async function () { + const text = 'hello world' + const rawKey = _key.masterKey + const nonce = await application.protocolService.crypto.generateRandomKey(192) + const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) + const aad = { foo: 'bar' } + const nonmatchingAad = { foo: 'rab' } + const encString = await operator.encryptString004(text, rawKey, nonce, aad) + const decString = await operator.decryptString004(encString, rawKey, nonce, nonmatchingAad) + expect(decString).to.not.be.ok + }) + + it('generates existing keys for key params', async function () { + const key = await application.protocolService.computeRootKey(_password, _keyParams) + expect(key.compare(_key)).to.be.true + }) + + it('can decrypt encrypted params', async function () { + const payload = Factory.createNotePayload() + const key = await protocol004.createItemsKey() + const params = await protocol004.generateEncryptedParametersSync(payload, key) + const decrypted = await protocol004.generateDecryptedParametersSync(params, key) + expect(decrypted.errorDecrypting).to.not.be.ok + expect(decrypted.content).to.eql(payload.content) + }) + + it('modifying the uuid of the payload should fail to decrypt', async function () { + const payload = Factory.createNotePayload() + const key = await protocol004.createItemsKey() + const params = await protocol004.generateEncryptedParametersSync(payload, key) + params.uuid = 'foo' + const result = await protocol004.generateDecryptedParametersSync(params, key) + expect(result.errorDecrypting).to.equal(true) + }) +}) diff --git a/packages/snjs/mocha/actions.test.js b/packages/snjs/mocha/actions.test.js new file mode 100644 index 000000000..8e6350c52 --- /dev/null +++ b/packages/snjs/mocha/actions.test.js @@ -0,0 +1,442 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import * as Utils from './lib/Utils.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('actions service', () => { + const errorProcessingActionMessage = 'An issue occurred while processing this action. Please try again.' + + before(async function () { + this.timeout(20000) + + localStorage.clear() + + this.application = await Factory.createInitAppWithFakeCrypto() + this.itemManager = this.application.itemManager + this.actionsManager = this.application.actionsManager + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const rootKey = await this.application.protocolService.createRootKey( + this.email, + this.password, + KeyParamsOrigination.Registration, + ) + this.authParams = rootKey.keyParams.content + + this.fakeServer = sinon.fakeServer.create() + this.fakeServer.respondImmediately = true + + this.actionsExtension = { + identifier: 'org.standardnotes.testing', + name: 'Test extension', + content_type: 'Extension', + url: 'http://my-extension.sn.org/get_actions/', + description: 'For testing purposes.', + supported_types: ['Note'], + actions: [ + { + label: 'Action #1', + url: 'http://my-extension.sn.org/action_1/', + verb: 'get', + context: '*', + content_types: ['Note'], + }, + { + label: 'Action #2', + url: 'http://my-extension.sn.org/action_2/', + verb: 'render', + context: 'Note', + content_types: ['Note'], + }, + { + label: 'Action #3', + url: 'http://my-extension.sn.org/action_3/', + verb: 'show', + context: 'Tag', + content_types: ['Note'], + }, + { + label: 'Action #5', + url: 'http://my-extension.sn.org/action_5/', + verb: 'render', + context: 'Note', + content_types: ['Note'], + }, + { + label: 'Action #7', + url: 'http://my-extension.sn.org/action_7/', + verb: 'nested', + context: 'Note', + content_types: ['Note'], + }, + ], + } + + this.fakeServer.respondWith('GET', /http:\/\/my-extension.sn.org\/get_actions\/(.*)/, (request, params) => { + const urlParams = new URLSearchParams(params) + const extension = Copy(this.actionsExtension) + + if (urlParams.has('item_uuid')) { + extension.actions.push({ + label: 'Action #4', + url: `http://my-extension.sn.org/action_4/?item_uuid=${urlParams.get('item_uuid')}`, + verb: 'post', + context: 'Item', + content_types: ['Note'], + access_type: 'decrypted', + }) + + extension.actions.push({ + label: 'Action #6', + url: `http://my-extension.sn.org/action_6/?item_uuid=${urlParams.get('item_uuid')}`, + verb: 'post', + context: 'Item', + content_types: ['Note'], + access_type: 'encrypted', + }) + } + + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(extension)) + }) + + const payload = new DecryptedPayload({ + uuid: Utils.generateUuid(), + content_type: ContentType.Note, + content: { + title: 'Testing', + }, + }) + + const encryptedPayload = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + + this.fakeServer.respondWith('GET', /http:\/\/my-extension.sn.org\/action_[1,2]\/(.*)/, (request) => { + request.respond( + 200, + { 'Content-Type': 'application/json' }, + JSON.stringify({ + item: encryptedPayload, + auth_params: this.authParams, + }), + ) + }) + + this.fakeServer.respondWith('GET', 'http://my-extension.sn.org/action_3/', [ + 200, + { 'Content-Type': 'text/html; charset=utf-8' }, + '

Action #3

', + ]) + + this.fakeServer.respondWith('POST', /http:\/\/my-extension.sn.org\/action_[4,6]\/(.*)/, (request) => { + const requestBody = JSON.parse(request.requestBody) + + const response = { + uuid: requestBody.items[0].uuid, + result: 'Action POSTed successfully.', + } + + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(response)) + }) + + this.fakeServer.respondWith('GET', 'http://my-extension.sn.org/action_5/', (request) => { + const encryptedPayloadClone = Copy(encryptedPayload) + + encryptedPayloadClone.items_key_id = undefined + encryptedPayloadClone.content = '003:somenonsense' + encryptedPayloadClone.enc_item_key = '003:anothernonsense' + encryptedPayloadClone.version = '003' + encryptedPayloadClone.uuid = 'fake-uuid' + + const payload = { + item: encryptedPayloadClone, + auth_params: this.authParams, + } + + request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(payload)) + }) + + // Extension item + const extensionItem = await this.application.itemManager.createItem( + ContentType.ActionsExtension, + this.actionsExtension, + ) + this.extensionItemUuid = extensionItem.uuid + }) + + after(async function () { + this.fakeServer.restore() + await Factory.safeDeinit(this.application) + this.application = null + localStorage.clear() + }) + + it('should get extension items', async function () { + await this.itemManager.createItem(ContentType.Note, { + title: 'A simple note', + text: 'Standard Notes rocks! lml.', + }) + const extensions = this.actionsManager.getExtensions() + expect(extensions.length).to.eq(1) + }) + + it('should get extensions in context of item', async function () { + const noteItem = await this.itemManager.createItem(ContentType.Note, { + title: 'Another note', + text: 'Whiskey In The Jar', + }) + + const noteItemExtensions = this.actionsManager.extensionsInContextOfItem(noteItem) + expect(noteItemExtensions.length).to.eq(1) + expect(noteItemExtensions[0].supported_types).to.include(noteItem.content_type) + }) + + it('should get actions based on item context', async function () { + const tagItem = await this.itemManager.createItem(ContentType.Tag, { + title: 'Music', + }) + + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + const tagActions = extensionItem.actionsWithContextForItem(tagItem) + + expect(tagActions.length).to.eq(1) + expect(tagActions.map((action) => action.label)).to.have.members(['Action #3']) + }) + + it('should load extension in context of item', async function () { + const noteItem = await this.itemManager.createItem(ContentType.Note, { + title: 'Yet another note', + text: 'And all things will end ♫', + }) + + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + expect(extensionItem.actions.length).to.be.eq(5) + + const extensionWithItem = await this.actionsManager.loadExtensionInContextOfItem(extensionItem, noteItem) + expect(extensionWithItem.actions.length).to.be.eq(7) + expect(extensionWithItem.actions.map((action) => action.label)).to.include.members([ + /** + * These actions were returned from the server + * and are relevant for the current item only. + */ + 'Action #4', + 'Action #6', + ]) + + // Actions that are relevant for an item should not be stored. + const updatedExtensionItem = await this.itemManager.findItem(this.extensionItemUuid) + const expectedActions = extensionItem.actions.map((action) => { + const { id, ...rest } = action + return rest + }) + expect(updatedExtensionItem.actions).to.containSubset(expectedActions) + }) + + describe('render action', async function () { + const sandbox = sinon.createSandbox() + + before(async function () { + this.noteItem = await this.itemManager.createItem(ContentType.Note, { + title: 'Hey', + text: 'Welcome To Paradise', + }) + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + this.renderAction = extensionItem.actions.filter((action) => action.verb === 'render')[0] + }) + + beforeEach(async function () { + this.alertServiceAlert = sandbox.spy(this.actionsManager.alertService, 'alert') + this.windowAlert = sandbox.stub(window, 'alert').callsFake((message) => message) + }) + + afterEach(async function () { + sandbox.restore() + }) + + it('should show an alert if the request fails', async function () { + this.httpServiceGetAbsolute = sandbox + .stub(this.actionsManager.httpService, 'getAbsolute') + .callsFake((url) => Promise.reject(new Error('Dummy error.'))) + + const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem) + + sinon.assert.calledOnceWithExactly(this.httpServiceGetAbsolute, this.renderAction.url) + sinon.assert.calledOnceWithExactly(this.alertServiceAlert, errorProcessingActionMessage) + expect(actionResponse.error.message).to.eq(errorProcessingActionMessage) + }) + + it('should return a response if payload is valid', async function () { + const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem) + + expect(actionResponse).to.have.property('item') + expect(actionResponse.item.payload.content.title).to.eq('Testing') + }) + + it('should return undefined if payload is invalid', async function () { + sandbox.stub(this.actionsManager, 'payloadByDecryptingResponse').returns(null) + + const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem) + expect(actionResponse).to.be.undefined + }) + + it('should return decrypted payload if password is valid', async function () { + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + this.renderAction = extensionItem.actions.filter((action) => action.verb === 'render')[0] + const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem) + + expect(actionResponse.item).to.be.ok + expect(actionResponse.item.title).to.be.equal('Testing') + }).timeout(20000) + }) + + describe('show action', async function () { + const sandbox = sinon.createSandbox() + + before(async function () { + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + this.showAction = extensionItem.actions[2] + }) + + beforeEach(async function () { + this.actionsManager.deviceInterface.openUrl = (url) => url + this.deviceInterfaceOpenUrl = sandbox.spy(this.actionsManager.deviceInterface, 'openUrl') + }) + + this.afterEach(async function () { + sandbox.restore() + }) + + it('should open the action url', async function () { + const response = await this.actionsManager.runAction(this.showAction) + + sandbox.assert.calledOnceWithExactly(this.deviceInterfaceOpenUrl, this.showAction.url) + expect(response).to.eql({}) + }) + }) + + describe('post action', async function () { + const sandbox = sinon.createSandbox() + + before(async function () { + this.noteItem = await this.itemManager.createItem(ContentType.Note, { + title: 'Excuse Me', + text: 'Time To Be King 8)', + }) + this.extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + this.extensionItem = await this.actionsManager.loadExtensionInContextOfItem(this.extensionItem, this.noteItem) + + this.decryptedPostAction = this.extensionItem.actions.filter( + (action) => action.access_type === 'decrypted' && action.verb === 'post', + )[0] + + this.encryptedPostAction = this.extensionItem.actions.filter( + (action) => action.access_type === 'encrypted' && action.verb === 'post', + )[0] + }) + + beforeEach(async function () { + this.alertServiceAlert = sandbox.spy(this.actionsManager.alertService, 'alert') + this.windowAlert = sandbox.stub(window, 'alert').callsFake((message) => message) + this.httpServicePostAbsolute = sandbox.stub(this.actionsManager.httpService, 'postAbsolute') + this.httpServicePostAbsolute.callsFake((url, params) => Promise.resolve(params)) + }) + + afterEach(async function () { + sandbox.restore() + }) + + it('should include generic encrypted payload within request body', async function () { + const response = await this.actionsManager.runAction(this.encryptedPostAction, this.noteItem) + + expect(response.items[0].enc_item_key).to.satisfy((string) => { + return string.startsWith(this.application.protocolService.getLatestVersion()) + }) + expect(response.items[0].uuid).to.eq(this.noteItem.uuid) + expect(response.items[0].auth_hash).to.not.be.ok + expect(response.items[0].content_type).to.be.ok + expect(response.items[0].created_at).to.be.ok + expect(response.items[0].content).to.satisfy((string) => { + return string.startsWith(this.application.protocolService.getLatestVersion()) + }) + }) + + it('should include generic decrypted payload within request body', async function () { + const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem) + + expect(response.items[0].uuid).to.eq(this.noteItem.uuid) + expect(response.items[0].enc_item_key).to.not.be.ok + expect(response.items[0].auth_hash).to.not.be.ok + expect(response.items[0].content_type).to.be.ok + expect(response.items[0].created_at).to.be.ok + expect(response.items[0].content.title).to.eq(this.noteItem.title) + expect(response.items[0].content.text).to.eq(this.noteItem.text) + }) + + it('should post to the action url', async function () { + this.httpServicePostAbsolute.restore() + const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem) + + expect(response).to.be.ok + expect(response.uuid).to.eq(this.noteItem.uuid) + expect(response.result).to.eq('Action POSTed successfully.') + }) + + it('should alert if an error occurred while processing the action', async function () { + this.httpServicePostAbsolute.restore() + const dummyError = new Error('Dummy error.') + + sandbox + .stub(this.actionsManager.httpService, 'postAbsolute') + .callsFake((url, params) => Promise.reject(dummyError)) + + const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem) + + sinon.assert.calledOnceWithExactly(this.alertServiceAlert, errorProcessingActionMessage) + expect(response).to.be.eq(dummyError) + }) + }) + + describe('nested action', async function () { + const sandbox = sinon.createSandbox() + + before(async function () { + const extensionItem = await this.itemManager.findItem(this.extensionItemUuid) + this.nestedAction = extensionItem.actions.filter((action) => action.verb === 'nested')[0] + }) + + beforeEach(async function () { + this.actionsManagerRunAction = sandbox.spy(this.actionsManager, 'runAction') + this.httpServiceRunHttp = sandbox.spy(this.actionsManager.httpService, 'runHttp') + this.actionResponse = await this.actionsManager.runAction(this.nestedAction) + }) + + afterEach(async function () { + sandbox.restore() + }) + + it('should return undefined', async function () { + expect(this.actionResponse).to.be.undefined + }) + + it('should call runAction once', async function () { + sandbox.assert.calledOnce(this.actionsManagerRunAction) + }) + + it('should not make any http requests', async function () { + sandbox.assert.notCalled(this.httpServiceRunHttp) + }) + }) +}) diff --git a/packages/snjs/mocha/app-group.test.js b/packages/snjs/mocha/app-group.test.js new file mode 100644 index 000000000..4cb7fba6d --- /dev/null +++ b/packages/snjs/mocha/app-group.test.js @@ -0,0 +1,109 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import WebDeviceInterface from './lib/web_device_interface.js' +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('application group', function () { + const globalDevice = new WebDeviceInterface(setTimeout.bind(window), setInterval.bind(window)) + + beforeEach(async function () { + localStorage.clear() + }) + + afterEach(async function () { + localStorage.clear() + }) + + it('initializing a group should result with primary application', async function () { + const group = new SNApplicationGroup(globalDevice) + await group.initialize({ + applicationCreator: (descriptor, deviceInterface) => { + return Factory.createApplicationWithFakeCrypto(descriptor.identifier, deviceInterface) + }, + }) + expect(group.primaryApplication).to.be.ok + expect(group.primaryApplication.identifier).to.be.ok + + await Factory.safeDeinit(group.primaryApplication) + }) + + it('initializing a group should result with proper descriptor setup', async function () { + const group = new SNApplicationGroup(globalDevice) + await group.initialize({ + applicationCreator: (descriptor, deviceInterface) => { + return Factory.createApplicationWithFakeCrypto(descriptor.identifier, deviceInterface) + }, + }) + const identifier = group.primaryApplication.identifier + expect(group.descriptorRecord[identifier].identifier).to.equal(identifier) + + await Factory.safeDeinit(group.primaryApplication) + }) + + it('should persist descriptor record after changes', async function () { + const group = new SNApplicationGroup(globalDevice) + await group.initialize({ + applicationCreator: (descriptor, device) => { + return Factory.createInitAppWithFakeCryptoWithOptions({ + device: device, + identifier: descriptor.identifier, + }) + }, + }) + const identifier = group.primaryApplication.identifier + + const descriptorRecord = await group.device.getJsonParsedRawStorageValue(RawStorageKey.DescriptorRecord) + expect(descriptorRecord[identifier].identifier).to.equal(identifier) + expect(descriptorRecord[identifier].primary).to.equal(true) + + await group.unloadCurrentAndCreateNewDescriptor() + const descriptorRecord2 = await globalDevice.getJsonParsedRawStorageValue(RawStorageKey.DescriptorRecord) + expect(Object.keys(descriptorRecord2).length).to.equal(2) + + expect(descriptorRecord2[identifier].primary).to.equal(false) + }) + + it('adding new application should incrememnt total descriptor count', async function () { + const group = new SNApplicationGroup(globalDevice) + await group.initialize({ + applicationCreator: (descriptor, device) => { + return Factory.createInitAppWithFakeCryptoWithOptions({ + device: device, + identifier: descriptor.identifier, + }) + }, + }) + + await group.unloadCurrentAndCreateNewDescriptor() + + expect(group.getDescriptors().length).to.equal(2) + }) + + it('should be notified when application changes', async function () { + const group = new SNApplicationGroup(globalDevice) + let notifyCount = 0 + const expectedCount = 2 + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve) => { + group.addEventObserver(() => { + notifyCount++ + if (notifyCount === expectedCount) { + resolve() + } + }) + await group.initialize({ + applicationCreator: (descriptor, device) => { + return Factory.createInitAppWithFakeCryptoWithOptions({ + device: device, + identifier: descriptor.identifier, + }) + }, + }) + await group.unloadCurrentAndCreateNewDescriptor() + }).then(() => { + expect(notifyCount).to.equal(expectedCount) + }) + }).timeout(1000) +}) diff --git a/packages/snjs/mocha/application.test.js b/packages/snjs/mocha/application.test.js new file mode 100644 index 000000000..4709e4311 --- /dev/null +++ b/packages/snjs/mocha/application.test.js @@ -0,0 +1,183 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('application instances', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async () => { + localStorage.clear() + }) + + afterEach(async () => { + localStorage.clear() + }) + + it('two distinct applications should not share model manager state', async () => { + const app1 = await Factory.createAndInitializeApplication('app1') + const app2 = await Factory.createAndInitializeApplication('app2') + expect(app1.payloadManager).to.equal(app1.payloadManager) + expect(app1.payloadManager).to.not.equal(app2.payloadManager) + + await Factory.createMappedNote(app1) + expect(app1.itemManager.items.length).length.to.equal(BASE_ITEM_COUNT + 1) + expect(app2.itemManager.items.length).to.equal(BASE_ITEM_COUNT) + await Factory.safeDeinit(app1) + await Factory.safeDeinit(app2) + }) + + it('two distinct applications should not share storage manager state', async () => { + const app1 = await Factory.createAndInitializeApplication('app1') + const app2 = await Factory.createAndInitializeApplication('app2') + + await Factory.createMappedNote(app1) + await app1.syncService.sync(syncOptions) + + expect((await app1.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1) + expect((await app2.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT) + + await Factory.createMappedNote(app2) + await app2.syncService.sync(syncOptions) + + expect((await app1.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1) + expect((await app2.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1) + await Factory.safeDeinit(app1) + await Factory.safeDeinit(app2) + }) + + it('deinit application while storage persisting should be handled gracefully', async () => { + /** This test will always succeed but should be observed for console exceptions */ + const app = await Factory.createAndInitializeApplication('app') + /** Don't await */ + app.diskStorageService.persistValuesToDisk() + await app.prepareForDeinit() + await Factory.safeDeinit(app) + }) + + it('changing default host should not affect already signed in accounts', async () => { + /** This test will always succeed but should be observed for console exceptions */ + const app = await Factory.createAndInitializeApplication( + 'app', + Environment.Web, + Platform.MacWeb, + Factory.getDefaultHost(), + ) + await Factory.registerUserToApplication({ + application: app, + email: UuidGenerator.GenerateUuid(), + password: 'password', + }) + await app.prepareForDeinit() + await Factory.safeDeinit(app) + + /** Recreate app with different host */ + const recreatedApp = await Factory.createAndInitializeApplication( + 'app', + Environment.Web, + Platform.MacWeb, + 'http://nonsense.host', + ) + + expect(recreatedApp.getHost()).to.not.equal('http://nonsense.host') + expect(recreatedApp.getHost()).to.equal(Factory.getDefaultHost()) + }) + + it('signing out application should delete snjs_version', async () => { + const identifier = 'app' + const app = await Factory.createAndInitializeApplication(identifier) + + expect(localStorage.getItem(`${identifier}-snjs_version`)).to.be.ok + + await app.user.signOut() + + expect(localStorage.getItem(`${identifier}-snjs_version`)).to.not.be.ok + }) + + it('locking application while critical func in progress should wait up to a limit', async () => { + /** This test will always succeed but should be observed for console exceptions */ + const app = await Factory.createAndInitializeApplication('app') + /** Don't await */ + const MaximumWaitTime = 0.5 + app.diskStorageService.executeCriticalFunction(async () => { + /** If we sleep less than the maximum, locking should occur safely. + * If we sleep more than the maximum, locking should occur with exception on + * app deinit. */ + await Factory.sleep(MaximumWaitTime - 0.05) + /** Access any deviceInterface function */ + app.diskStorageService.deviceInterface.getAllRawDatabasePayloads(app.identifier) + }) + await app.lock() + }) + + describe('signOut()', () => { + let testNote1 + let confirmAlert + let deinit + let testSNApp + + const signOutConfirmMessage = (numberOfItems) => { + const singular = numberOfItems === 1 + return ( + `There ${singular ? 'is' : 'are'} ${numberOfItems} ${singular ? 'item' : 'items'} with unsynced changes. ` + + 'If you sign out, these changes will be lost forever. Are you sure you want to sign out?' + ) + } + + beforeEach(async () => { + testSNApp = await Factory.createAndInitializeApplication('test-application') + testNote1 = await Factory.createMappedNote(testSNApp, 'Note 1', 'This is a test note!', false) + confirmAlert = sinon.spy(testSNApp.alertService, 'confirm') + deinit = sinon.spy(testSNApp, 'deinit') + }) + + it('shows confirmation dialog when there are unsaved changes', async () => { + await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.user.signOut() + + const expectedConfirmMessage = signOutConfirmMessage(1) + + expect(confirmAlert.callCount).to.equal(1) + expect(confirmAlert.calledWith(expectedConfirmMessage)).to.be.ok + expect(deinit.callCount).to.equal(1) + expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok + }) + + it('does not show confirmation dialog when there are no unsaved changes', async () => { + await testSNApp.user.signOut() + + expect(confirmAlert.callCount).to.equal(0) + expect(deinit.callCount).to.equal(1) + expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok + }) + + it('does not show confirmation dialog when there are unsaved changes and the "force" option is set to true', async () => { + await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.user.signOut(true) + + expect(confirmAlert.callCount).to.equal(0) + expect(deinit.callCount).to.equal(1) + expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok + }) + + it('cancels sign out if confirmation dialog is rejected', async () => { + confirmAlert.restore() + confirmAlert = sinon.stub(testSNApp.alertService, 'confirm').callsFake((_message) => false) + + await testSNApp.itemManager.setItemDirty(testNote1) + await testSNApp.user.signOut() + + const expectedConfirmMessage = signOutConfirmMessage(1) + + expect(confirmAlert.callCount).to.equal(1) + expect(confirmAlert.calledWith(expectedConfirmMessage)).to.be.ok + expect(deinit.callCount).to.equal(0) + }) + }) +}) diff --git a/packages/snjs/mocha/assets/mocha.css b/packages/snjs/mocha/assets/mocha.css new file mode 100644 index 000000000..4c47c122b --- /dev/null +++ b/packages/snjs/mocha/assets/mocha.css @@ -0,0 +1,288 @@ +@charset "utf-8"; + +body { + margin: 0; + background-color: rgb(160, 160, 160); +} + +#mocha { + font: 20px/1.5 'Helvetica Neue', Helvetica, Arial, sans-serif; + margin: 60px 50px; +} + +#mocha ul, +#mocha li { + margin: 0; + padding: 0; +} + +#mocha ul { + list-style: none; +} + +#mocha h1, +#mocha h2 { + margin: 0; +} + +#mocha h1 { + margin-top: 15px; + font-size: 1em; + font-weight: 200; +} + +#mocha h1 a { + text-decoration: none; + color: inherit; +} + +#mocha h1 a:hover { + text-decoration: underline; +} + +#mocha .suite .suite h1 { + margin-top: 0; + font-size: 0.8em; +} + +#mocha .hidden { + display: none; +} + +#mocha h2 { + font-size: 12px; + font-weight: normal; + cursor: pointer; +} + +#mocha .suite { + margin-left: 15px; +} + +#mocha .test { + margin-left: 15px; + overflow: hidden; +} + +#mocha .test.pending:hover h2::after { + content: '(pending)'; + font-family: arial, sans-serif; +} + +#mocha .test.pass.medium .duration { + background: #c09853; +} + +#mocha .test.pass.slow .duration { + background: #b94a48; +} + +#mocha .test.pass::before { + content: '✓'; + font-size: 12px; + display: block; + float: left; + margin-right: 5px; + color: #00d6b2; +} + +#mocha .test.pass .duration { + font-size: 9px; + margin-left: 5px; + padding: 2px 5px; + color: #fff; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2); + -moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2); + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + -ms-border-radius: 5px; + -o-border-radius: 5px; + border-radius: 5px; +} + +#mocha .test.pass.fast .duration { + display: none; +} + +#mocha .test.pending { + color: #f0ec00; +} + +#mocha .test.pending::before { + content: '◦'; + color: #f0ec00; +} + +#mocha .test.fail { + color: #c00; +} + +#mocha .test.fail pre { + color: black; +} + +#mocha .test.fail::before { + content: '✖'; + font-size: 12px; + display: block; + float: left; + margin-right: 5px; + color: #c00; +} + +#mocha .test pre.error { + color: #c00; + max-height: 300px; + overflow: auto; +} + +/** + * (1): approximate for browsers not supporting calc + * (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border) + * ^^ seriously + */ +#mocha .test pre { + display: block; + float: left; + clear: left; + font: 12px/1.5 monaco, monospace; + margin: 5px; + padding: 15px; + border: 1px solid #eee; + max-width: 85%; + /*(1)*/ + max-width: calc(100% - 42px); + /*(2)*/ + word-wrap: break-word; + border-bottom-color: #ddd; + -webkit-border-radius: 3px; + -webkit-box-shadow: 0 1px 3px #eee; + -moz-border-radius: 3px; + -moz-box-shadow: 0 1px 3px #eee; + border-radius: 3px; +} + +#mocha .test h2 { + position: relative; +} + +#mocha .test a.replay { + position: absolute; + top: 3px; + right: 0; + text-decoration: none; + vertical-align: middle; + display: block; + width: 15px; + height: 15px; + line-height: 15px; + text-align: center; + background: #eee; + font-size: 15px; + -moz-border-radius: 15px; + border-radius: 15px; + -webkit-transition: opacity 200ms; + -moz-transition: opacity 200ms; + transition: opacity 200ms; + opacity: 0.3; + color: #888; +} + +#mocha .test:hover a.replay { + opacity: 1; +} + +#mocha-report.pass .test.fail { + display: none; +} + +#mocha-report.fail .test.pass { + display: none; +} + +#mocha-report.pending .test.pass, +#mocha-report.pending .test.fail { + display: none; +} + +#mocha-report.pending .test.pass.pending { + display: block; +} + +#mocha-error { + color: #c00; + font-size: 1.5em; + font-weight: 100; + letter-spacing: 1px; +} + +#mocha-stats { + position: fixed; + top: 15px; + right: 10px; + font-size: 12px; + margin: 0; + color: black; + z-index: 1; +} + +#mocha-stats .progress { + float: right; + padding-top: 0; +} + +#mocha-stats em { + color: #ddd; +} + +#mocha-stats a { + text-decoration: none; + color: inherit; +} + +#mocha-stats a:hover { + border-bottom: 1px solid #eee; +} + +#mocha-stats li { + display: inline-block; + margin: 0 5px; + list-style: none; + padding-top: 11px; +} + +#mocha-stats canvas { + width: 40px; + height: 40px; +} + +#mocha code .comment { + color: #ddd; +} + +#mocha code .init { + color: #2f6fad; +} + +#mocha code .string { + color: #5890ad; +} + +#mocha code .keyword { + color: #8a6343; +} + +#mocha code .number { + color: #2f6fad; +} + +@media screen and (max-device-width: 480px) { + #mocha { + margin: 60px 0px; + } + + #mocha #stats { + position: absolute; + } +} diff --git a/packages/snjs/mocha/assets/small_file.md b/packages/snjs/mocha/assets/small_file.md new file mode 100644 index 000000000..4263a59af --- /dev/null +++ b/packages/snjs/mocha/assets/small_file.md @@ -0,0 +1,4 @@ +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + + + diff --git a/packages/snjs/mocha/assets/two_mb_file.md b/packages/snjs/mocha/assets/two_mb_file.md new file mode 100644 index 000000000..67d7d251f --- /dev/null +++ b/packages/snjs/mocha/assets/two_mb_file.md @@ -0,0 +1,2906 @@ +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on t. pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on tpencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on tpencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on tpencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on tpencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on tApple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly on + +Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.Apple throws at users, without going back a huge marketing challenge. Interact said they didn’t like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a car’s power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designer’s workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-hes the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once. + + + + + + + diff --git a/packages/snjs/mocha/auth-fringe-cases.test.js b/packages/snjs/mocha/auth-fringe-cases.test.js new file mode 100644 index 000000000..0b2898042 --- /dev/null +++ b/packages/snjs/mocha/auth-fringe-cases.test.js @@ -0,0 +1,111 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('auth fringe cases', () => { + const BASE_ITEM_COUNT = ['default items key', 'user prefs'].length + + const createContext = async () => { + const application = await Factory.createInitAppWithFakeCrypto() + return { + expectedItemCount: BASE_ITEM_COUNT, + application: application, + email: UuidGenerator.GenerateUuid(), + password: UuidGenerator.GenerateUuid(), + deinit: async () => { + await Factory.safeDeinit(application) + }, + } + } + + beforeEach(async function () { + localStorage.clear() + }) + + afterEach(async function () { + localStorage.clear() + }) + + const clearApplicationLocalStorage = function () { + const keys = Object.keys(localStorage) + for (const key of keys) { + if (!key.toLowerCase().includes('item')) { + localStorage.removeItem(key) + } + } + } + + const awaitSync = true + + describe('localStorage improperly cleared with 1 item', function () { + it('item should be errored', async function () { + const context = await createContext() + await context.application.register(context.email, context.password) + const note = await Factory.createSyncedNote(context.application) + clearApplicationLocalStorage() + + console.warn("Expecting errors 'Unable to find operator for version undefined'") + + const restartedApplication = await Factory.restartApplication(context.application) + const refreshedNote = restartedApplication.payloadManager.findOne(note.uuid) + expect(refreshedNote.errorDecrypting).to.equal(true) + + await Factory.safeDeinit(restartedApplication) + }) + + it('signing in again should decrypt item', async function () { + const context = await createContext() + await context.application.register(context.email, context.password) + const note = await Factory.createSyncedNote(context.application) + clearApplicationLocalStorage() + const restartedApplication = await Factory.restartApplication(context.application) + + console.warn( + "Expecting errors 'No associated key found for item encrypted with latest protocol version.'", + "and 'Unable to find operator for version undefined'", + ) + + await restartedApplication.signIn(context.email, context.password, undefined, undefined, undefined, awaitSync) + const refreshedNote = restartedApplication.itemManager.findItem(note.uuid) + expect(isDecryptedItem(refreshedNote)).to.equal(true) + expect(restartedApplication.itemManager.getDisplayableNotes().length).to.equal(1) + await Factory.safeDeinit(restartedApplication) + }).timeout(10000) + }) + + describe('having offline item matching remote item uuid', function () { + it('offline item should not overwrite recently updated server item and conflict should be created', async function () { + const context = await createContext() + await context.application.register(context.email, context.password) + + const staleText = 'stale text' + + const firstVersionOfNote = await Factory.createSyncedNote(context.application, undefined, staleText) + + const serverText = 'server text' + + await context.application.mutator.changeAndSaveItem(firstVersionOfNote, (mutator) => { + mutator.text = serverText + }) + + const newApplication = await Factory.signOutApplicationAndReturnNew(context.application) + + /** Create same note but now offline */ + await newApplication.itemManager.emitItemFromPayload(firstVersionOfNote.payload) + + /** Sign in and merge local data */ + await newApplication.signIn(context.email, context.password, undefined, undefined, true, true) + + expect(newApplication.itemManager.getDisplayableNotes().length).to.equal(2) + + expect(newApplication.itemManager.getDisplayableNotes().find((n) => n.uuid === firstVersionOfNote.uuid).text).to.equal(staleText) + + const conflictedCopy = newApplication.itemManager.getDisplayableNotes().find((n) => n.uuid !== firstVersionOfNote.uuid) + expect(conflictedCopy.text).to.equal(serverText) + expect(conflictedCopy.duplicate_of).to.equal(firstVersionOfNote.uuid) + await Factory.safeDeinit(newApplication) + }).timeout(10000) + }) +}) diff --git a/packages/snjs/mocha/auth.test.js b/packages/snjs/mocha/auth.test.js new file mode 100644 index 000000000..3b93aa0f2 --- /dev/null +++ b/packages/snjs/mocha/auth.test.js @@ -0,0 +1,519 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('basic auth', function () { + this.timeout(Factory.TenSecondTimeout) + + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + localStorage.clear() + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + it('successfully register new account', async function () { + const response = await this.application.register(this.email, this.password) + expect(response).to.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + }) + + it('fails register new account with short password', async function () { + const password = '123456' + + let error = null + try { + await this.application.register(this.email, password) + } catch(caughtError) { + error = caughtError + } + + expect(error.message).to.equal('Your password must be at least 8 characters in length. ' + + 'For your security, please choose a longer password or, ideally, a passphrase, and try again.') + + expect(await this.application.protocolService.getRootKey()).to.not.be.ok + }) + + it('successfully signs out of account', async function () { + await this.application.register(this.email, this.password) + + expect(await this.application.protocolService.getRootKey()).to.be.ok + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + expect(await this.application.protocolService.getRootKey()).to.not.be.ok + expect(this.application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(BASE_ITEM_COUNT) + }) + + it('successfully signs in to registered account', async function () { + await this.application.register(this.email, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + }).timeout(20000) + + it('cannot sign while already signed in', async function () { + await this.application.register(this.email, this.password) + await Factory.createSyncedNote(this.application) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + + let error + try { + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + } catch (e) { + error = e + } + expect(error).to.be.ok + }).timeout(20000) + + it('cannot register while already signed in', async function () { + await this.application.register(this.email, this.password) + let error + try { + await this.application.register(this.email, this.password) + } catch (e) { + error = e + } + expect(error).to.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + }).timeout(20000) + + it('cannot perform two sign-ins at the same time', async function () { + await this.application.register(this.email, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + await Promise.all([ + (async () => { + const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + })(), + (async () => { + /** Make sure the first function runs first */ + await new Promise((resolve) => setTimeout(resolve)) + /** Try to sign in while the first request is going */ + let error + try { + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + } catch (e) { + error = e + } + expect(error).to.be.ok + })(), + ]) + }).timeout(20000) + + it('cannot perform two register operations at the same time', async function () { + await Promise.all([ + (async () => { + const response = await this.application.register(this.email, this.password) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + })(), + (async () => { + /** Make sure the first function runs first */ + await new Promise((resolve) => setTimeout(resolve)) + /** Try to register in while the first request is going */ + let error + try { + await this.application.register(this.email, this.password) + } catch (e) { + error = e + } + expect(error).to.be.ok + })(), + ]) + }).timeout(20000) + + it('successfuly signs in after failing once', async function () { + await this.application.register(this.email, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + let response = await this.application.signIn(this.email, 'wrong password', undefined, undefined, undefined, true) + expect(response).to.have.property('status', 401) + expect(response.error).to.be.ok + + response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + + expect(response.status).to.equal(200) + expect(response).to.not.haveOwnProperty('error') + }).timeout(20000) + + it('server retrieved key params should use our client inputted value for identifier', async function () { + /** + * We should ensure that when we retrieve key params from the server, in order to generate a root + * key server password for login, that the identifier used in the key params is the client side entered + * value, and not the value returned from the server. + * + * Apart from wanting to minimze trust from the server, we also want to ensure that if + * we register with an uppercase identifier, and request key params with the lowercase equivalent, + * that even though the server performs a case-insensitive search on email fields, we correct + * for this action locally. + */ + const rand = `${Math.random()}` + const uppercase = `FOO@BAR.COM${rand}` + const lowercase = `foo@bar.com${rand}` + /** + * Registering with an uppercase email should still allow us to sign in + * with lowercase email + */ + await this.application.register(uppercase, this.password) + + const response = await this.application.sessionManager.retrieveKeyParams(lowercase) + const keyParams = response.keyParams + expect(keyParams.identifier).to.equal(lowercase) + expect(keyParams.identifier).to.not.equal(uppercase) + }).timeout(20000) + + it('can sign into account regardless of email case', async function () { + const rand = `${Math.random()}` + const uppercase = `FOO@BAR.COM${rand}` + const lowercase = `foo@bar.com${rand}` + /** + * Registering with a lowercase email should allow us to sign in + * with an uppercase email + */ + await this.application.register(lowercase, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const response = await this.application.signIn(uppercase, this.password, undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + }).timeout(20000) + + it('can sign into account regardless of whitespace', async function () { + const rand = `${Math.random()}` + const withspace = `FOO@BAR.COM${rand} ` + const nospace = `foo@bar.com${rand}` + /** + * Registering with a lowercase email should allow us to sign in + * with an uppercase email + */ + await this.application.register(nospace, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const response = await this.application.signIn(withspace, this.password, undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + }).timeout(20000) + + it('fails login with wrong password', async function () { + await this.application.register(this.email, this.password) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const response = await this.application.signIn(this.email, 'wrongpassword', undefined, undefined, undefined, true) + expect(response).to.be.ok + expect(response.error).to.be.ok + expect(await this.application.protocolService.getRootKey()).to.not.be.ok + }).timeout(20000) + + it('fails to change to short password', async function () { + await this.application.register(this.email, this.password) + const newPassword = '123456' + const response = await this.application.changePassword(this.password, newPassword) + expect(response.error).to.be.ok + }).timeout(20000) + + it('fails to change password when current password is incorrect', async function () { + await this.application.register(this.email, this.password) + const response = await this.application.changePassword('Invalid password', 'New password') + expect(response.error).to.be.ok + + /** Ensure we can still log in */ + this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password) + }).timeout(20000) + + it('registering for new account and completing first after download sync should not put us out of sync', async function () { + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + + let outOfSync = true + let didCompletePostDownloadFirstSync = false + let didCompleteDownloadFirstSync = false + this.application.syncService.addEventObserver((eventName) => { + if (eventName === SyncEvent.DownloadFirstSyncCompleted) { + didCompleteDownloadFirstSync = true + } + if (!didCompleteDownloadFirstSync) { + return + } + if (!didCompletePostDownloadFirstSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + didCompletePostDownloadFirstSync = true + /** Should be in sync */ + outOfSync = this.application.syncService.isOutOfSync() + } + }) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + expect(didCompleteDownloadFirstSync).to.equal(true) + expect(didCompletePostDownloadFirstSync).to.equal(true) + expect(outOfSync).to.equal(false) + }) + + async function changePassword() { + await this.application.register(this.email, this.password) + + const noteCount = 10 + + await Factory.createManyMappedNotes(this.application, noteCount) + + this.expectedItemCount += noteCount + + await this.application.syncService.sync(syncOptions) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + const newPassword = 'newpassword' + const response = await this.application.changePassword(this.password, newPassword) + + /** New items key */ + this.expectedItemCount++ + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + expect(response.error).to.not.be.ok + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + await this.application.syncService.sync(syncOptions) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + const note = this.application.itemManager.getDisplayableNotes()[0] + + /** + * Create conflict for a note. First modify the item without saving so that + * our local contents digress from the server's + */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: `${Math.random()}`, + }, + syncOptions, + ) + this.expectedItemCount++ + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + /** Should login with new password */ + const signinResponse = await this.application.signIn(this.email, newPassword, undefined, undefined, undefined, true) + + expect(signinResponse).to.be.ok + expect(signinResponse.error).to.not.be.ok + + expect(await this.application.protocolService.getRootKey()).to.be.ok + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + } + + it('successfully changes password', changePassword).timeout(40000) + + it.skip('successfully changes password when passcode is set', async function () { + const passcode = 'passcode' + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, passcode)) + } else { + values.push(CreateChallengeValue(prompt, this.password)) + } + } + return values + } + this.application.setLaunchCallback({ + receiveChallenge: (challenge) => { + this.application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + this.application.submitValuesForChallenge(challenge, values) + numPasscodeAttempts++ + }, + }) + const initialValues = promptValueReply(challenge.prompts) + this.application.submitValuesForChallenge(challenge, initialValues) + }, + }) + await this.application.setPasscode(passcode) + await changePassword.bind(this)() + }).timeout(20000) + + it('changes password many times', async function () { + await this.application.register(this.email, this.password) + + const noteCount = 10 + await Factory.createManyMappedNotes(this.application, noteCount) + this.expectedItemCount += noteCount + await this.application.syncService.sync(syncOptions) + + const numTimesToChangePw = 3 + let newPassword = Factory.randomString() + let currentPassword = this.password + + for (let i = 0; i < numTimesToChangePw; i++) { + await this.application.changePassword(currentPassword, newPassword) + + /** New items key */ + this.expectedItemCount++ + + currentPassword = newPassword + newPassword = Factory.randomString() + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + await this.application.syncService.sync(syncOptions) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + expect(this.application.itemManager.items.length).to.equal(BASE_ITEM_COUNT) + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + + /** Should login with new password */ + const signinResponse = await this.application.signIn( + this.email, + currentPassword, + undefined, + undefined, + undefined, + true, + ) + + expect(signinResponse).to.be.ok + expect(signinResponse.error).to.not.be.ok + expect(await this.application.protocolService.getRootKey()).to.be.ok + } + }).timeout(80000) + + it('signing in with a clean email string should only try once', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const performSignIn = sinon.spy(this.application.sessionManager, 'performSignIn') + await this.application.signIn(this.email, 'wrong password', undefined, undefined, undefined, true) + expect(performSignIn.callCount).to.equal(1) + }) + + describe('add passcode', function () { + it('should set passcode successfully', async function () { + const passcode = 'passcode' + const result = await this.application.addPasscode(passcode) + expect(result).to.be.true + }) + + it('should fail when attempting to set 0 character passcode', async function () { + const passcode = '' + const result = await this.application.addPasscode(passcode) + expect(result).to.be.false + }) + }) + + describe('change passcode', function () { + it('should change passcode successfully', async function () { + const passcode = 'passcode' + const newPasscode = 'newPasscode' + await this.application.addPasscode(passcode) + Factory.handlePasswordChallenges(this.application, passcode) + const result = await this.application.changePasscode(newPasscode) + expect(result).to.be.true + }).timeout(Factory.TenSecondTimeout) + + it('should fail when attempting to change to a 0 character passcode', async function () { + const passcode = 'passcode' + const newPasscode = '' + await this.application.addPasscode(passcode) + Factory.handlePasswordChallenges(this.application, passcode) + const result = await this.application.changePasscode(newPasscode) + expect(result).to.be.false + }).timeout(Factory.TenSecondTimeout) + }) + + describe.skip('account deletion', function () { + it('should delete account', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + Factory.handlePasswordChallenges(this.application, this.password) + const _response = await this.application.user.deleteAccount() + }).timeout(Factory.TenSecondTimeout) + + it('should prompt for account password when deleting account', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + Factory.handlePasswordChallenges(this.application, this.password) + + const _response = await this.application.deleteAccount() + + sinon.spy(snApp.challengeService, 'sendChallenge') + const spyCall = snApp.challengeService.sendChallenge.getCall(0) + const challenge = spyCall.firstArg + expect(challenge.prompts).to.have.lengthOf(2) + expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword) + // ... + }).timeout(Factory.TenSecondTimeout) + + it('deleting account should sign out current user', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + Factory.handlePasswordChallenges(this.application, this.password) + + const _response = await this.application.deleteAccount() + + expect(application.hasAccount()).to.be.false + }).timeout(Factory.TenSecondTimeout) + }) +}) diff --git a/packages/snjs/mocha/backups.test.js b/packages/snjs/mocha/backups.test.js new file mode 100644 index 000000000..7b4775f19 --- /dev/null +++ b/packages/snjs/mocha/backups.test.js @@ -0,0 +1,221 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('backups', function () { + before(function () { + localStorage.clear() + }) + + after(function () { + localStorage.clear() + }) + + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + this.application = null + }) + + const BASE_ITEM_COUNT_ENCRYPTED = ['ItemsKey', 'UserPreferences'].length + const BASE_ITEM_COUNT_DECRYPTED = ['UserPreferences'].length + + it('backup file should have a version number', async function () { + let data = await this.application.createDecryptedBackupFile() + expect(data.version).to.equal(this.application.protocolService.getLatestVersion()) + await this.application.addPasscode('passcode') + data = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(data.version).to.equal(this.application.protocolService.getLatestVersion()) + }) + + it('no passcode + no account backup file should have correct number of items', async function () { + await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)]) + const data = await this.application.createDecryptedBackupFile() + expect(data.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + }) + + it('passcode + no account backup file should have correct number of items', async function () { + const passcode = 'passcode' + await this.application.addPasscode(passcode) + await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)]) + + // Encrypted backup without authorization + const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + + // Encrypted backup with authorization + Factory.handlePasswordChallenges(this.application, passcode) + const authorizedEncryptedData = await this.application.createEncryptedBackupFile() + expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + }) + + it('no passcode + account backup file should have correct number of items', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)]) + + // Encrypted backup without authorization + const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + + Factory.handlePasswordChallenges(this.application, this.password) + + // Decrypted backup + const decryptedData = await this.application.createDecryptedBackupFile() + expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + + // Encrypted backup with authorization + const authorizedEncryptedData = await this.application.createEncryptedBackupFile() + expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + }) + + it('passcode + account backup file should have correct number of items', async function () { + this.timeout(10000) + const passcode = 'passcode' + await this.application.register(this.email, this.password) + Factory.handlePasswordChallenges(this.application, this.password) + await this.application.addPasscode(passcode) + await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)]) + + // Encrypted backup without authorization + const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + + Factory.handlePasswordChallenges(this.application, passcode) + + // Decrypted backup + const decryptedData = await this.application.createDecryptedBackupFile() + expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + + // Encrypted backup with authorization + const authorizedEncryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2) + }) + + it('backup file item should have correct fields', async function () { + await Factory.createSyncedNote(this.application) + let backupData = await this.application.createDecryptedBackupFile() + let rawItem = backupData.items.find((i) => i.content_type === ContentType.Note) + + expect(rawItem.fields).to.not.be.ok + expect(rawItem.source).to.not.be.ok + expect(rawItem.dirtyIndex).to.not.be.ok + expect(rawItem.format).to.not.be.ok + expect(rawItem.uuid).to.be.ok + expect(rawItem.content_type).to.be.ok + expect(rawItem.content).to.be.ok + expect(rawItem.created_at).to.be.ok + expect(rawItem.updated_at).to.be.ok + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + backupData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + rawItem = backupData.items.find((i) => i.content_type === ContentType.Note) + + expect(rawItem.fields).to.not.be.ok + expect(rawItem.source).to.not.be.ok + expect(rawItem.dirtyIndex).to.not.be.ok + expect(rawItem.format).to.not.be.ok + expect(rawItem.uuid).to.be.ok + expect(rawItem.content_type).to.be.ok + expect(rawItem.content).to.be.ok + expect(rawItem.created_at).to.be.ok + expect(rawItem.updated_at).to.be.ok + }) + + it('downloading backup if item is error decrypting should succeed', async function () { + await Factory.createSyncedNote(this.application) + + const note = await Factory.createSyncedNote(this.application) + + const encrypted = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note.payload], + }, + }) + + const errored = encrypted.copy({ + errorDecrypting: true, + }) + + await this.application.itemManager.emitItemFromPayload(errored) + + const erroredItem = this.application.itemManager.findAnyItem(errored.uuid) + + expect(erroredItem.errorDecrypting).to.equal(true) + + const backupData = await this.application.createDecryptedBackupFile() + + expect(backupData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2) + }) + + it('decrypted backup file should not have keyParams', async function () { + const backup = await this.application.createDecryptedBackupFile() + expect(backup).to.not.haveOwnProperty('keyParams') + }) + + it('decrypted backup file with account should not have keyParams', async function () { + const application = await Factory.createInitAppWithFakeCrypto() + const password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password: password, + }) + + Factory.handlePasswordChallenges(application, password) + + const backup = await application.createDecryptedBackupFile() + + expect(backup).to.not.haveOwnProperty('keyParams') + + await Factory.safeDeinit(application) + }) + + it('encrypted backup file should have keyParams', async function () { + await this.application.addPasscode('passcode') + const backup = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(backup).to.haveOwnProperty('keyParams') + }) + + it('decrypted backup file should not have itemsKeys', async function () { + const backup = await this.application.createDecryptedBackupFile() + expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.false + }) + + it('encrypted backup file should have itemsKeys', async function () { + await this.application.addPasscode('passcode') + const backup = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.true + }) + + it('backup file with no account and no passcode should be decrypted', async function () { + const note = await Factory.createSyncedNote(this.application) + const backup = await this.application.createDecryptedBackupFile() + expect(backup).to.not.haveOwnProperty('keyParams') + expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.false + expect(backup.items.find((item) => item.content_type === ContentType.Note).uuid).to.equal(note.uuid) + let error + try { + await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + } catch (e) { + error = e + } + expect(error).to.be.ok + }) +}) diff --git a/packages/snjs/mocha/collections.test.js b/packages/snjs/mocha/collections.test.js new file mode 100644 index 000000000..01eb229cd --- /dev/null +++ b/packages/snjs/mocha/collections.test.js @@ -0,0 +1,84 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import { createRelatedNoteTagPairPayload } from './lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('payload collections', () => { + before(async () => { + localStorage.clear() + }) + + after(async () => { + localStorage.clear() + }) + + const copyNote = (note, timestamp, changeUuid) => { + return new SNNote( + note.payload.copy({ + uuid: changeUuid ? Factory.generateUuidish() : note.payload.uuid, + created_at: timestamp ? new Date(timestamp) : new Date(), + }), + ) + } + + it('find', async () => { + const payload = Factory.createNotePayload() + const collection = ImmutablePayloadCollection.WithPayloads([payload]) + expect(collection.find(payload.uuid)).to.be.ok + }) + + it('references', async () => { + const payloads = createRelatedNoteTagPairPayload() + const notePayload = payloads[0] + const tagPayload = payloads[1] + const collection = ImmutablePayloadCollection.WithPayloads([notePayload, tagPayload]) + const referencing = collection.elementsReferencingElement(notePayload) + expect(referencing.length).to.equal(1) + }) + + it('references by content type', async () => { + const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload() + const collection = ImmutablePayloadCollection.WithPayloads([notePayload1, tagPayload1]) + const referencingTags = collection.elementsReferencingElement(notePayload1, ContentType.Tag) + expect(referencingTags.length).to.equal(1) + expect(referencingTags[0].uuid).to.equal(tagPayload1.uuid) + + const referencingNotes = collection.elementsReferencingElement(notePayload1, ContentType.Note) + expect(referencingNotes.length).to.equal(0) + }) + + it('conflict map', async () => { + const payload = Factory.createNotePayload() + const collection = new PayloadCollection() + collection.set([payload]) + const conflict = payload.copy({ + content: { + conflict_of: payload.uuid, + ...payload.content, + }, + }) + collection.set([conflict]) + + expect(collection.conflictsOf(payload.uuid)).to.eql([conflict]) + + const manualResults = collection.all().find((p) => { + return p.content.conflict_of === payload.uuid + }) + expect(collection.conflictsOf(payload.uuid)).to.eql([manualResults]) + }) + + it('setting same element twice should not yield duplicates', async () => { + const collection = new PayloadCollection() + const payload = Factory.createNotePayload() + + const copy = payload.copy() + collection.set([payload, copy]) + collection.set([payload]) + collection.set([payload, copy]) + + const sorted = collection.all(ContentType.Note) + expect(sorted.length).to.equal(1) + }) +}) diff --git a/packages/snjs/mocha/device_auth.test.js b/packages/snjs/mocha/device_auth.test.js new file mode 100644 index 000000000..a4dcd0fdc --- /dev/null +++ b/packages/snjs/mocha/device_auth.test.js @@ -0,0 +1,164 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('device authentication', function () { + beforeEach(async function () { + localStorage.clear() + }) + + afterEach(async function () { + localStorage.clear() + }) + + it('handles application launch with passcode only', async function () { + const namespace = Factory.randomString() + const application = await Factory.createAndInitializeApplication(namespace) + const passcode = 'foobar' + const wrongPasscode = 'barfoo' + expect(await application.protectionService.createLaunchChallenge()).to.not.be.ok + await application.addPasscode(passcode) + expect(await application.hasPasscode()).to.equal(true) + expect(await application.protectionService.createLaunchChallenge()).to.be.ok + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await Factory.safeDeinit(application) + + /** Recreate application and initialize */ + const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace) + let numPasscodeAttempts = 0 + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, numPasscodeAttempts < 2 ? wrongPasscode : passcode)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + tmpApplication.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + tmpApplication.submitValuesForChallenge(challenge, values) + numPasscodeAttempts++ + }, + }) + const initialValues = promptValueReply(challenge.prompts) + tmpApplication.submitValuesForChallenge(challenge, initialValues) + } + await tmpApplication.prepareForLaunch({ receiveChallenge }) + expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok + await tmpApplication.launch(true) + expect(await tmpApplication.protocolService.getRootKey()).to.be.ok + expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await Factory.safeDeinit(tmpApplication) + }).timeout(10000) + + it('handles application launch with passcode and biometrics', async function () { + const namespace = Factory.randomString() + const application = await Factory.createAndInitializeApplication(namespace) + const passcode = 'foobar' + const wrongPasscode = 'barfoo' + await application.addPasscode(passcode) + await application.protectionService.enableBiometrics() + expect(await application.hasPasscode()).to.equal(true) + expect((await application.protectionService.createLaunchChallenge()).prompts.length).to.equal(2) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await Factory.safeDeinit(application) + + /** Recreate application and initialize */ + const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace) + let numPasscodeAttempts = 1 + + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + const response = { prompt, value: numPasscodeAttempts < 2 ? wrongPasscode : passcode } + values.push(response) + } else if (prompt.validation === ChallengeValidation.Biometric) { + values.push({ prompt, value: true }) + } + } + return values + } + + const receiveChallenge = async (challenge) => { + tmpApplication.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + tmpApplication.submitValuesForChallenge(challenge, values) + numPasscodeAttempts++ + }, + }) + const initialValues = promptValueReply(challenge.prompts) + tmpApplication.submitValuesForChallenge(challenge, initialValues) + } + + await tmpApplication.prepareForLaunch({ receiveChallenge }) + expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok + expect((await tmpApplication.protectionService.createLaunchChallenge()).prompts.length).to.equal(2) + await tmpApplication.launch(true) + expect(await tmpApplication.protocolService.getRootKey()).to.be.ok + expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await Factory.safeDeinit(tmpApplication) + }).timeout(Factory.TwentySecondTimeout) + + it('handles application launch with passcode and account', async function () { + const namespace = Factory.randomString() + const application = await Factory.createAndInitializeApplication(namespace) + const email = UuidGenerator.GenerateUuid() + const password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: application, + email, + password, + }) + const sampleStorageKey = 'foo' + const sampleStorageValue = 'bar' + await application.diskStorageService.setValue(sampleStorageKey, sampleStorageValue) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + const passcode = 'foobar' + Factory.handlePasswordChallenges(application, password) + await application.addPasscode(passcode) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + expect(await application.hasPasscode()).to.equal(true) + await Factory.safeDeinit(application) + + const wrongPasscode = 'barfoo' + let numPasscodeAttempts = 1 + /** Recreate application and initialize */ + const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace) + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + values.push({ prompt, value: numPasscodeAttempts < 2 ? wrongPasscode : passcode }) + } + } + return values + } + const receiveChallenge = async (challenge) => { + tmpApplication.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + tmpApplication.submitValuesForChallenge(challenge, values) + numPasscodeAttempts++ + }, + }) + const initialValues = promptValueReply(challenge.prompts) + tmpApplication.submitValuesForChallenge(challenge, initialValues) + } + await tmpApplication.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok + await tmpApplication.launch(true) + expect(await tmpApplication.diskStorageService.getValue(sampleStorageKey)).to.equal(sampleStorageValue) + expect(await tmpApplication.protocolService.getRootKey()).to.be.ok + expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + await Factory.safeDeinit(tmpApplication) + }).timeout(Factory.TwentySecondTimeout) +}) diff --git a/packages/snjs/mocha/features.test.js b/packages/snjs/mocha/features.test.js new file mode 100644 index 000000000..26487343a --- /dev/null +++ b/packages/snjs/mocha/features.test.js @@ -0,0 +1,315 @@ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('features', () => { + let application + let email + let password + let midnightThemeFeature + let plusEditorFeature + let tagNestingFeature + let getUserFeatures + + beforeEach(async function () { + application = await Factory.createInitAppWithFakeCrypto() + + const now = new Date() + const tomorrow = now.setDate(now.getDate() + 1) + + midnightThemeFeature = { + ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.MidnightTheme), + expires_at: tomorrow, + } + plusEditorFeature = { + ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.PlusEditor), + expires_at: tomorrow, + } + tagNestingFeature = { + ...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.TagNesting), + expires_at: tomorrow, + } + + sinon.spy(application.itemManager, 'createItem') + sinon.spy(application.itemManager, 'changeComponent') + sinon.spy(application.itemManager, 'setItemsToBeDeleted') + + getUserFeatures = sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => { + return Promise.resolve({ + data: { + features: [midnightThemeFeature, plusEditorFeature, tagNestingFeature], + }, + }) + }) + + email = UuidGenerator.GenerateUuid() + password = UuidGenerator.GenerateUuid() + + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + }) + + afterEach(async function () { + Factory.safeDeinit(application) + sinon.restore() + }) + + describe('new user roles received on api response meta', () => { + it('should save roles and features', async () => { + expect(application.featuresService.roles).to.have.lengthOf(1) + expect(application.featuresService.roles[0]).to.equal('CORE_USER') + + expect(application.featuresService.features).to.have.lengthOf(3) + expect(application.featuresService.features[0]).to.containSubset(midnightThemeFeature) + expect(application.featuresService.features[1]).to.containSubset(plusEditorFeature) + + const storedRoles = await application.getValue(StorageKey.UserRoles) + + expect(storedRoles).to.have.lengthOf(1) + expect(storedRoles[0]).to.equal('CORE_USER') + + const storedFeatures = await application.getValue(StorageKey.UserFeatures) + + expect(storedFeatures).to.have.lengthOf(3) + expect(storedFeatures[0]).to.containSubset(midnightThemeFeature) + expect(storedFeatures[1]).to.containSubset(plusEditorFeature) + expect(storedFeatures[2]).to.containSubset(tagNestingFeature) + }) + + it('should fetch user features and create items for features with content type', async () => { + expect(application.apiService.getUserFeatures.callCount).to.equal(1) + expect(application.itemManager.createItem.callCount).to.equal(2) + const themeItems = application.items.getItems(ContentType.Theme) + const editorItems = application.items.getItems(ContentType.Component) + expect(themeItems).to.have.lengthOf(1) + expect(editorItems).to.have.lengthOf(1) + expect(themeItems[0].content).to.containSubset( + JSON.parse( + JSON.stringify({ + name: midnightThemeFeature.name, + package_info: midnightThemeFeature, + valid_until: new Date(midnightThemeFeature.expires_at), + }), + ), + ) + expect(editorItems[0].content).to.containSubset( + JSON.parse( + JSON.stringify({ + name: plusEditorFeature.name, + area: plusEditorFeature.area, + package_info: plusEditorFeature, + valid_until: new Date(midnightThemeFeature.expires_at), + }), + ), + ) + }) + + it('should update content for existing feature items', async () => { + // Wipe items from initial sync + await application.itemManager.removeAllItemsFromMemory() + // Wipe roles from initial sync + await application.featuresService.setRoles([]) + // Create pre-existing item for theme without all the info + await application.itemManager.createItem( + ContentType.Theme, + FillItemContent({ + package_info: { + identifier: FeatureIdentifier.MidnightTheme, + }, + }), + ) + // Call sync intentionally to get roles again in meta + await application.sync.sync() + // Timeout since we don't await for features update + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(application.itemManager.changeComponent.callCount).to.equal(1) + const themeItems = application.items.getItems(ContentType.Theme) + expect(themeItems).to.have.lengthOf(1) + expect(themeItems[0].content).to.containSubset( + JSON.parse( + JSON.stringify({ + package_info: midnightThemeFeature, + valid_until: new Date(midnightThemeFeature.expires_at), + }), + ), + ) + }) + + it('should delete theme item if feature has expired', async () => { + const now = new Date() + const yesterday = now.setDate(now.getDate() - 1) + + getUserFeatures.restore() + sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => { + return Promise.resolve({ + data: { + features: [ + { + ...midnightThemeFeature, + expires_at: yesterday, + }, + ], + }, + }) + }) + + const themeItem = application.items.getItems(ContentType.Theme)[0] + + // Wipe roles from initial sync + await application.featuresService.setRoles([]) + + // Call sync intentionally to get roles again in meta + await application.sync.sync() + + // Timeout since we don't await for features update + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(application.itemManager.setItemsToBeDeleted.calledWith([sinon.match({ uuid: themeItem.uuid })])).to.equal( + true, + ) + + const noTheme = application.items.getItems(ContentType.Theme)[0] + expect(noTheme).to.not.be.ok + }) + }) + + it('should provide feature', async () => { + const feature = application.features.getUserFeature(FeatureIdentifier.PlusEditor) + expect(feature).to.containSubset(plusEditorFeature) + }) + + describe('extension repo items observer', () => { + it('should migrate to user setting when extension repo is added', async () => { + sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => { + return false + }) + + expect(await application.settings.getDoesSensitiveSettingExist(SettingName.ExtensionKey)).to.equal(false) + + const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') + + const promise = new Promise((resolve) => { + sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve) + }) + + await application.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + url: `https://extensions.standardnotes.org/${extensionKey}`, + }), + ) + + await promise + }) + + it('signing into account with ext repo should migrate it', async () => { + sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => { + return false + }) + /** Attach an ExtensionRepo object to an account, but prevent it from being migrated. + * Then sign out, sign back in, and ensure the item is migrated. */ + /** Prevent migration from running */ + sinon + .stub(application.featuresService, 'migrateFeatureRepoToUserSetting') + // eslint-disable-next-line @typescript-eslint/no-empty-function + .callsFake(() => {}) + const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') + await application.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + url: `https://extensions.standardnotes.org/${extensionKey}`, + }), + true, + ) + await application.sync.sync() + application = await Factory.signOutApplicationAndReturnNew(application) + + sinon.restore() + sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => { + return false + }) + const promise = new Promise((resolve) => { + sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve) + }) + await Factory.loginToApplication({ + application, + email, + password, + }) + await promise + }) + + it('having an ext repo with no account, then signing into account, should migrate it', async () => { + application = await Factory.signOutApplicationAndReturnNew(application) + sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => { + return false + }) + const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') + await application.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + url: `https://extensions.standardnotes.org/${extensionKey}`, + }), + true, + ) + await application.sync.sync() + + const promise = new Promise((resolve) => { + sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve) + }) + await Factory.loginToApplication({ + application, + email, + password, + }) + await promise + }) + + it.skip('migrated ext repo should have property indicating it was migrated', async () => { + sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => { + return false + }) + expect(await application.settings.getDoesSensitiveSettingExist(SettingName.ExtensionKey)).to.equal(false) + const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') + const promise = new Promise((resolve) => { + application.streamItems(ContentType.ExtensionRepo, ({ changed }) => { + for (const item of changed) { + if (item.content.migratedToUserSetting) { + resolve() + } + } + }) + }) + await application.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + url: `https://extensions.standardnotes.org/${extensionKey}`, + }), + ) + await promise + }) + }) + + describe('offline features migration', () => { + it('previous extension repo should be migrated to offline feature repo', async () => { + application = await Factory.signOutApplicationAndReturnNew(application) + const extensionKey = UuidGenerator.GenerateUuid().split('-').join('') + await application.itemManager.createItem( + ContentType.ExtensionRepo, + FillItemContent({ + url: `https://extensions.standardnotes.org/${extensionKey}`, + }), + true, + ) + await application.sync.sync() + + const repo = application.featuresService.getOfflineRepo() + expect(repo.migratedToOfflineEntitlements).to.equal(true) + expect(repo.offlineFeaturesUrl).to.equal('https://api.standardnotes.com/v1/offline/features') + expect(repo.offlineKey).to.equal(extensionKey) + }) + }) +}) diff --git a/packages/snjs/mocha/files.test.js b/packages/snjs/mocha/files.test.js new file mode 100644 index 000000000..dca38b34a --- /dev/null +++ b/packages/snjs/mocha/files.test.js @@ -0,0 +1,188 @@ +import * as Factory from './lib/factory.js' +import * as Utils from './lib/Utils.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('files', function () { + this.timeout(Factory.TwentySecondTimeout) + + let application + let context + let fileService + let itemManager + + beforeEach(function () { + localStorage.clear() + }) + + const setup = async ({ fakeCrypto, subscription = true }) => { + if (fakeCrypto) { + context = await Factory.createAppContextWithFakeCrypto() + } else { + context = await Factory.createAppContextWithRealCrypto() + } + + await context.launch() + + application = context.application + fileService = context.application.fileService + itemManager = context.application.itemManager + + await Factory.registerUserToApplication({ + application: context.application, + email: context.email, + password: context.password, + }) + + if (subscription) { + await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + userEmail: context.email, + subscriptionId: 1, + subscriptionName: 'PLUS_PLAN', + subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000, + timestamp: Date.now(), + offline: false, + }) + await Factory.sleep(0.25) + } + } + + afterEach(async function () { + await Factory.safeDeinit(application) + localStorage.clear() + }) + + const uploadFile = async (fileService, buffer, name, ext, chunkSize) => { + const operation = await fileService.beginNewFileUpload() + + let chunkId = 1 + for (let i = 0; i < buffer.length; i += chunkSize) { + const readUntil = i + chunkSize > buffer.length ? buffer.length : i + chunkSize + const chunk = buffer.slice(i, readUntil) + const isFinalChunk = readUntil === buffer.length + + const error = await fileService.pushBytesForUpload(operation, chunk, chunkId++, isFinalChunk) + if (error) { + throw new Error('Could not upload file chunk') + } + } + + const file = await fileService.finishUpload(operation, name, ext) + + return file + } + + const downloadFile = async (fileService, itemManager, remoteIdentifier) => { + const file = itemManager.getItems(ContentType.File).find((file) => file.remoteIdentifier === remoteIdentifier) + + let receivedBytes = new Uint8Array() + + await fileService.downloadFile(file, (decryptedBytes) => { + receivedBytes = new Uint8Array([...receivedBytes, ...decryptedBytes]) + }) + + return receivedBytes + } + + it('should create valet token from server', async function () { + await setup({ fakeCrypto: true, subscription: true }) + const remoteIdentifier = Utils.generateUuid() + const token = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + + expect(token.length).to.be.above(0) + }) + + it('should not create valet token from server when user has no subscription', async function () { + await setup({ fakeCrypto: true, subscription: false }) + + const remoteIdentifier = Utils.generateUuid() + const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + + expect(tokenOrError.tag).to.equal('no-subscription') + }) + + it('should not create valet token from server when user has an expired subscription', async function () { + await setup({ fakeCrypto: true, subscription: false }) + + await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + userEmail: context.email, + subscriptionId: 1, + subscriptionName: 'PLUS_PLAN', + subscriptionExpiresAt: (new Date().getTime() - 3_600_000) * 1_000, + timestamp: Date.now(), + offline: false, + }) + + await Factory.sleep(0.25) + + const remoteIdentifier = Utils.generateUuid() + const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write') + + expect(tokenOrError.tag).to.equal('expired-subscription') + }) + + it('creating two upload sessions successively should succeed', async function () { + await setup({ fakeCrypto: true, subscription: true }) + + const firstToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write') + const firstSession = await application.apiService.startUploadSession(firstToken) + + expect(firstSession.uploadId).to.be.ok + + const secondToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write') + const secondSession = await application.apiService.startUploadSession(secondToken) + + expect(secondSession.uploadId).to.be.ok + }) + + it('should encrypt and upload small file', async function () { + await setup({ fakeCrypto: false, subscription: true }) + + const response = await fetch('/packages/snjs/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const file = await uploadFile(fileService, buffer, 'my-file', 'md', 1000) + + const downloadedBytes = await downloadFile(fileService, itemManager, file.remoteIdentifier) + + expect(downloadedBytes).to.eql(buffer) + }) + + it('should encrypt and upload big file', async function () { + await setup({ fakeCrypto: false, subscription: true }) + + const response = await fetch('/packages/snjs/mocha/assets/two_mb_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const file = await uploadFile(fileService, buffer, 'my-file', 'md', 100000) + + const downloadedBytes = await downloadFile(fileService, itemManager, file.remoteIdentifier) + + expect(downloadedBytes).to.eql(buffer) + }) + + it('should delete file', async function () { + await setup({ fakeCrypto: false, subscription: true }) + + const response = await fetch('/packages/snjs/mocha/assets/small_file.md') + const buffer = new Uint8Array(await response.arrayBuffer()) + + const file = await uploadFile(fileService, buffer, 'my-file', 'md', 1000) + + const error = await fileService.deleteFile(file) + + expect(error).to.not.be.ok + + expect(itemManager.findItem(file.uuid)).to.not.be.ok + + const downloadError = await fileService.downloadFile(file) + + expect(downloadError).to.be.ok + }) + + it.skip('should cancel file download', async function () { + await setup({ fakeCrypto: false, subscription: true }) + + // ... + }) +}) diff --git a/packages/snjs/mocha/history.test.js b/packages/snjs/mocha/history.test.js new file mode 100644 index 000000000..99625a88f --- /dev/null +++ b/packages/snjs/mocha/history.test.js @@ -0,0 +1,410 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import { createNoteParams } from './lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('history manager', () => { + const largeCharacterChange = 25 + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(function () { + localStorage.clear() + }) + + afterEach(function () { + localStorage.clear() + }) + + describe('session', function () { + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + this.historyManager = this.application.historyManager + this.payloadManager = this.application.payloadManager + /** Automatically optimize after every revision by setting this to 0 */ + this.historyManager.itemRevisionThreshold = 0 + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + function setTextAndSync(application, item, text) { + return application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.text = text + }, + undefined, + undefined, + syncOptions, + ) + } + + function deleteCharsFromString(string, amount) { + return string.substring(0, string.length - amount) + } + + it('create basic history entries 1', async function () { + const item = await Factory.createSyncedNote(this.application) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(0) + + /** Sync with same contents, should not create new entry */ + await Factory.markDirtyAndSyncItem(this.application, item) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(0) + + /** Sync with different contents, should create new entry */ + await this.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(1) + }) + + it('first change should create revision with previous value', async function () { + const identifier = this.application.identifier + const item = await Factory.createSyncedNote(this.application) + + /** Simulate loading new application session */ + const context = await Factory.createAppContext({ identifier }) + await context.launch() + expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0) + await context.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + const entries = context.application.historyManager.sessionHistoryForItem(item) + expect(entries.length).to.equal(1) + expect(entries[0].payload.content.title).to.equal(item.content.title) + await context.deinit() + }) + + it('creating new item and making 1 change should create 0 revisions', async function () { + const context = await Factory.createAppContext() + await context.launch() + const item = await context.application.mutator.createTemplateItem(ContentType.Note, { + references: [], + }) + await context.application.mutator.insertItem(item) + expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0) + + await context.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0) + await context.deinit() + }) + + it('should optimize basic entries', async function () { + let item = await Factory.createSyncedNote(this.application) + /** + * Add 1 character. This typically would be discarded as an entry, but it + * won't here because it's the first change, which we want to keep. + */ + await setTextAndSync(this.application, item, item.content.text + '1') + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(1) + + /** + * Changing it by one character should keep this entry, + * since it's now the last (and will keep the first) + */ + item = await setTextAndSync(this.application, item, item.content.text + '2') + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2) + /** + * Change it over the largeCharacterChange threshold. It should keep this + * revision, but now remove the previous revision, since it's no longer + * the last, and is a small change. + */ + item = await setTextAndSync( + this.application, + item, + item.content.text + Factory.randomString(largeCharacterChange + 1), + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2) + + item = await setTextAndSync( + this.application, + item, + item.content.text + Factory.randomString(largeCharacterChange + 1), + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2) + /** Delete over threshold text. */ + item = await setTextAndSync( + this.application, + item, + deleteCharsFromString(item.content.text, largeCharacterChange + 1), + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(3) + /** + * Delete just 1 character. It should now retain the previous revision, as well as the + * one previous to that. + */ + item = await setTextAndSync(this.application, item, deleteCharsFromString(item.content.text, 1)) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(4) + item = await setTextAndSync(this.application, item, deleteCharsFromString(item.content.text, 1)) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(5) + }) + + it('should keep the entry right before a large deletion, regardless of its delta', async function () { + const payload = new DecryptedPayload( + createNoteParams({ + text: Factory.randomString(100), + }), + ) + let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.itemManager.setItemDirty(item) + await this.application.syncService.sync(syncOptions) + /** It should keep the first and last by default */ + item = await setTextAndSync(this.application, item, item.content.text) + item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1)) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2) + item = await setTextAndSync( + this.application, + item, + deleteCharsFromString(item.content.text, largeCharacterChange + 1), + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2) + item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1)) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(3) + item = await setTextAndSync( + this.application, + item, + item.content.text + Factory.randomString(largeCharacterChange + 1), + ) + expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(4) + }) + + it('entries should be ordered from newest to oldest', async function () { + const payload = new DecryptedPayload( + createNoteParams({ + text: Factory.randomString(200), + }), + ) + + let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + + await this.application.itemManager.setItemDirty(item) + await this.application.syncService.sync(syncOptions) + + item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1)) + + item = await setTextAndSync( + this.application, + item, + deleteCharsFromString(item.content.text, largeCharacterChange + 1), + ) + + item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1)) + + item = await setTextAndSync( + this.application, + item, + item.content.text + Factory.randomString(largeCharacterChange + 1), + ) + + /** First entry should be the latest revision. */ + const latestRevision = this.historyManager.sessionHistoryForItem(item)[0] + /** Last entry should be the initial revision. */ + const initialRevision = + this.historyManager.sessionHistoryForItem(item)[this.historyManager.sessionHistoryForItem(item).length - 1] + + expect(latestRevision).to.not.equal(initialRevision) + + expect(latestRevision.textCharDiffLength).to.equal(1) + expect(initialRevision.textCharDiffLength).to.equal(200) + /** Finally, the latest revision updated_at value date should be more recent than the initial revision one. */ + expect(latestRevision.itemFromPayload().userModifiedDate).to.be.greaterThan( + initialRevision.itemFromPayload().userModifiedDate, + ) + }).timeout(10000) + + it('unsynced entries should use payload created_at for preview titles', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const item = this.application.items.findItem(payload.uuid) + await this.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + const historyItem = this.historyManager.sessionHistoryForItem(item)[0] + expect(historyItem.previewTitle()).to.equal(historyItem.payload.created_at.toLocaleString()) + }) + }) + + describe('remote', function () { + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + this.historyManager = this.application.historyManager + this.payloadManager = this.application.payloadManager + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('response from server should be empty if not signed in', async function () { + await this.application.user.signOut() + this.application = await Factory.createInitAppWithFakeCrypto() + this.historyManager = this.application.historyManager + this.payloadManager = this.application.payloadManager + const item = await Factory.createSyncedNote(this.application) + await this.application.syncService.sync(syncOptions) + const itemHistory = await this.historyManager.remoteHistoryForItem(item) + expect(itemHistory).to.be.undefined + }) + + it('create basic history entries 2', async function () { + const item = await Factory.createSyncedNote(this.application) + let itemHistory = await this.historyManager.remoteHistoryForItem(item) + + /** Server history should save initial revision */ + expect(itemHistory).to.be.ok + expect(itemHistory.length).to.equal(1) + + /** Sync within 5 minutes, should not create a new entry */ + await Factory.markDirtyAndSyncItem(this.application, item) + itemHistory = await this.historyManager.remoteHistoryForItem(item) + expect(itemHistory.length).to.equal(1) + + /** Sync with different contents, should not create a new entry */ + await this.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + itemHistory = await this.historyManager.remoteHistoryForItem(item) + expect(itemHistory.length).to.equal(1) + }) + + it('returns revisions from server', async function () { + let item = await Factory.createSyncedNote(this.application) + + await Factory.sleep(Factory.ServerRevisionFrequency) + /** Sync with different contents, should create new entry */ + const newTitleAfterFirstChange = `The title should be: ${Math.random()}` + await this.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.title = newTitleAfterFirstChange + }, + undefined, + undefined, + syncOptions, + ) + let itemHistory = await this.historyManager.remoteHistoryForItem(item) + expect(itemHistory.length).to.equal(2) + + const oldestEntry = lastElement(itemHistory) + let revisionFromServer = await this.historyManager.fetchRemoteRevision(item, oldestEntry) + expect(revisionFromServer).to.be.ok + + let payloadFromServer = revisionFromServer.payload + expect(payloadFromServer.errorDecrypting).to.be.undefined + expect(payloadFromServer.uuid).to.eq(item.payload.uuid) + expect(payloadFromServer.content).to.eql(item.payload.content) + + item = this.application.itemManager.findItem(item.uuid) + expect(payloadFromServer.content).to.not.eql(item.payload.content) + }) + + it('duplicate revisions should not have the originals uuid', async function () { + const note = await Factory.createSyncedNote(this.application) + await Factory.markDirtyAndSyncItem(this.application, note) + const dupe = await this.application.itemManager.duplicateItem(note, true) + await Factory.markDirtyAndSyncItem(this.application, dupe) + + const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe) + const dupeRevision = await this.historyManager.fetchRemoteRevision(dupe, dupeHistory[0]) + expect(dupeRevision.payload.uuid).to.equal(dupe.uuid) + }) + + it.skip('revisions count matches original for duplicated items', async function () { + /** + * We can't handle duplicate item revision because the server copies over revisions + * via a background job which we can't predict the timing of. This test is thus invalid. + */ + const note = await Factory.createSyncedNote(this.application) + + /** Make a few changes to note */ + await Factory.sleep(Factory.ServerRevisionFrequency) + await Factory.markDirtyAndSyncItem(this.application, note) + + await Factory.sleep(Factory.ServerRevisionFrequency) + await Factory.markDirtyAndSyncItem(this.application, note) + + await Factory.sleep(Factory.ServerRevisionFrequency) + await Factory.markDirtyAndSyncItem(this.application, note) + + const dupe = await this.application.itemManager.duplicateItem(note, true) + await Factory.markDirtyAndSyncItem(this.application, dupe) + + const expectedRevisions = 3 + const noteHistory = await this.historyManager.remoteHistoryForItem(note) + const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe) + expect(noteHistory.length).to.equal(expectedRevisions) + expect(dupeHistory.length).to.equal(expectedRevisions) + }) + + it.skip('can decrypt revisions for duplicate_of items', async function () { + /** + * We can't handle duplicate item revision because the server copies over revisions + * via a background job which we can't predict the timing of. This test is thus invalid. + */ + const note = await Factory.createSyncedNote(this.application) + await Factory.sleep(Factory.ServerRevisionFrequency) + const changedText = `${Math.random()}` + /** Make a few changes to note */ + await this.application.mutator.changeAndSaveItem(note, (mutator) => { + mutator.title = changedText + }) + await Factory.markDirtyAndSyncItem(this.application, note) + + const dupe = await this.application.itemManager.duplicateItem(note, true) + await Factory.markDirtyAndSyncItem(this.application, dupe) + const itemHistory = await this.historyManager.remoteHistoryForItem(dupe) + expect(itemHistory.length).to.be.above(1) + const oldestRevision = lastElement(itemHistory) + + const fetched = await this.historyManager.fetchRemoteRevision(dupe, oldestRevision) + expect(fetched.payload.errorDecrypting).to.not.be.ok + expect(fetched.payload.content.title).to.equal(changedText) + }) + }) +}) diff --git a/packages/snjs/mocha/item.test.js b/packages/snjs/mocha/item.test.js new file mode 100644 index 000000000..9d7c4882d --- /dev/null +++ b/packages/snjs/mocha/item.test.js @@ -0,0 +1,93 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('item', () => { + beforeEach(async function () { + this.createBarePayload = () => { + return new DecryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: { + title: 'hello', + }, + }) + } + + this.createNote = () => { + return new DecryptedItem(this.createBarePayload()) + } + + this.createTag = (notes = []) => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return new SNTag( + new DecryptedPayload({ + uuid: Factory.generateUuidish(), + content_type: ContentType.Tag, + content: { + title: 'thoughts', + references: references, + }, + }), + ) + } + }) + + it('constructing without uuid should throw', function () { + let error + + try { + new DecryptedItem({}) + } catch (e) { + error = e + } + + expect(error).to.be.ok + }) + + it('healthy constructor', function () { + const item = this.createNote() + + expect(item).to.be.ok + expect(item.payload).to.be.ok + }) + + it('user modified date should be ok', function () { + const item = this.createNote() + + expect(item.userModifiedDate).to.be.ok + }) + + it('has relationship with item true', function () { + const note = this.createNote() + const tag = this.createTag() + + expect(tag.isReferencingItem(note)).to.equal(false) + }) + + it('has relationship with item true', function () { + const note = this.createNote() + const tag = this.createTag([note]) + + expect(tag.isReferencingItem(note)).to.equal(true) + }) + + it('getDomainData for random domain should return undefined', function () { + const note = this.createNote() + + expect(note.getDomainData('random')).to.not.be.ok + }) + + it('getDomainData for app domain should return object', function () { + const note = this.createNote() + + expect(note.getDomainData(DecryptedItem.DefaultAppDomain())).to.be.ok + }) +}) diff --git a/packages/snjs/mocha/item_manager.test.js b/packages/snjs/mocha/item_manager.test.js new file mode 100644 index 000000000..14fd8bf73 --- /dev/null +++ b/packages/snjs/mocha/item_manager.test.js @@ -0,0 +1,602 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('item manager', function () { + beforeEach(async function () { + this.payloadManager = new PayloadManager() + this.itemManager = new ItemManager(this.payloadManager) + this.createNote = async () => { + return this.itemManager.createItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + } + + this.createTag = async (notes = []) => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return this.itemManager.createItem(ContentType.Tag, { + title: 'thoughts', + references: references, + }) + } + }) + + it('create item', async function () { + const item = await this.createNote() + + expect(item).to.be.ok + expect(item.title).to.equal('hello') + }) + + it('emitting item through payload and marking dirty should have userModifiedDate', async function () { + const payload = Factory.createNotePayload() + const item = await this.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const result = await this.itemManager.setItemDirty(item) + const appData = result.payload.content.appData + expect(appData[DecryptedItem.DefaultAppDomain()][AppDataField.UserModifiedDate]).to.be.ok + }) + + it('find items with valid uuid', async function () { + const item = await this.createNote() + + const results = await this.itemManager.findItems([item.uuid]) + expect(results.length).to.equal(1) + expect(results[0]).to.equal(item) + }) + + it('find items with invalid uuid no blanks', async function () { + const results = await this.itemManager.findItems([Factory.generateUuidish()]) + expect(results.length).to.equal(0) + }) + + it('find items with invalid uuid include blanks', async function () { + const includeBlanks = true + const results = await this.itemManager.findItemsIncludingBlanks([Factory.generateUuidish()]) + expect(results.length).to.equal(1) + expect(results[0]).to.not.be.ok + }) + + it('item state', async function () { + await this.createNote() + + expect(this.itemManager.items.length).to.equal(1) + expect(this.itemManager.getDisplayableNotes().length).to.equal(1) + }) + + it('find item', async function () { + const item = await this.createNote() + + const foundItem = this.itemManager.findItem(item.uuid) + expect(foundItem).to.be.ok + }) + + it('reference map', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + + expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([note.uuid]) + }) + + it('inverse reference map', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + + expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([tag.uuid]) + }) + + it('inverse reference map should not have duplicates', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + await this.itemManager.changeItem(tag) + + expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([tag.uuid]) + }) + + it('deleting from reference map', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + await this.itemManager.setItemToBeDeleted(note) + + expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([]) + expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid].length).to.equal(0) + }) + + it('deleting referenced item should update referencing item references', async function () { + const note = await this.createNote() + let tag = await this.createTag([note]) + await this.itemManager.setItemToBeDeleted(note) + + tag = this.itemManager.findItem(tag.uuid) + expect(tag.content.references.length).to.equal(0) + }) + + it('removing relationship should update reference map', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + await this.itemManager.changeItem(tag, (mutator) => { + mutator.removeItemAsRelationship(note) + }) + + expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([]) + expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([]) + }) + + it('emitting discardable payload should remove it from our collection', async function () { + const note = await this.createNote() + + const payload = new DeletedPayload({ + ...note.payload.ejected(), + content: undefined, + deleted: true, + dirty: false, + }) + + expect(payload.discardable).to.equal(true) + + await this.itemManager.emitItemFromPayload(payload) + + expect(this.itemManager.findItem(note.uuid)).to.not.be.ok + }) + + it('items that reference item', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + + const itemsThatReference = this.itemManager.itemsReferencingItem(note) + expect(itemsThatReference.length).to.equal(1) + expect(itemsThatReference[0]).to.equal(tag) + }) + + it('observer', async function () { + const observed = [] + this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, source, sourceKey }) => { + observed.push({ changed, inserted, removed, source, sourceKey }) + }) + const note = await this.createNote() + const tag = await this.createTag([note]) + expect(observed.length).to.equal(2) + + const firstObserved = observed[0] + expect(firstObserved.inserted).to.eql([note]) + + const secondObserved = observed[1] + expect(secondObserved.inserted).to.eql([tag]) + }) + + it('change existing item', async function () { + const note = await this.createNote() + const newTitle = String(Math.random()) + await this.itemManager.changeItem(note, (mutator) => { + mutator.title = newTitle + }) + + const latestVersion = this.itemManager.findItem(note.uuid) + expect(latestVersion.title).to.equal(newTitle) + }) + + it('change non-existant item through uuid should fail', async function () { + const note = await this.itemManager.createTemplateItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + + const changeFn = async () => { + const newTitle = String(Math.random()) + return this.itemManager.changeItem(note, (mutator) => { + mutator.title = newTitle + }) + } + await Factory.expectThrowsAsync(() => changeFn(), 'Attempting to change non-existant item') + }) + + it('set items dirty', async function () { + const note = await this.createNote() + await this.itemManager.setItemDirty(note) + + const dirtyItems = this.itemManager.getDirtyItems() + expect(dirtyItems.length).to.equal(1) + expect(dirtyItems[0].uuid).to.equal(note.uuid) + expect(dirtyItems[0].dirty).to.equal(true) + }) + + it('dirty items should not include errored items', async function () { + const note = await this.itemManager.setItemDirty(await this.createNote()) + const errorred = new EncryptedPayload({ + ...note.payload, + content: '004:...', + errorDecrypting: true, + }) + + await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + + const dirtyItems = this.itemManager.getDirtyItems() + + expect(dirtyItems.length).to.equal(0) + }) + + it('dirty items should include errored items if they are being deleted', async function () { + const note = await this.itemManager.setItemDirty(await this.createNote()) + const errorred = new DeletedPayload({ + ...note.payload, + content: undefined, + errorDecrypting: true, + deleted: true, + }) + + await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + + const dirtyItems = this.itemManager.getDirtyItems() + + expect(dirtyItems.length).to.equal(1) + }) + + describe('duplicateItem', async function () { + const sandbox = sinon.createSandbox() + + beforeEach(async function () { + this.emitPayloads = sandbox.spy(this.itemManager.payloadManager, 'emitPayloads') + }) + + afterEach(async function () { + sandbox.restore() + }) + + it('should duplicate the item and set the duplicate_of property', async function () { + const note = await this.createNote() + await this.itemManager.duplicateItem(note) + sinon.assert.calledTwice(this.emitPayloads) + + const originalNote = this.itemManager.getDisplayableNotes()[0] + const duplicatedNote = this.itemManager.getDisplayableNotes()[1] + + expect(this.itemManager.items.length).to.equal(2) + expect(this.itemManager.getDisplayableNotes().length).to.equal(2) + expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) + expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) + expect(duplicatedNote.conflictOf).to.be.undefined + expect(duplicatedNote.payload.content.conflict_of).to.be.undefined + }) + + it('should duplicate the item and set the duplicate_of and conflict_of properties', async function () { + const note = await this.createNote() + await this.itemManager.duplicateItem(note, true) + sinon.assert.calledTwice(this.emitPayloads) + + const originalNote = this.itemManager.getDisplayableNotes()[0] + const duplicatedNote = this.itemManager.getDisplayableNotes()[1] + + expect(this.itemManager.items.length).to.equal(2) + expect(this.itemManager.getDisplayableNotes().length).to.equal(2) + expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid) + expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of) + expect(originalNote.uuid).to.equal(duplicatedNote.conflictOf) + expect(originalNote.uuid).to.equal(duplicatedNote.payload.content.conflict_of) + }) + + it('duplicate item with relationships', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + const duplicate = await this.itemManager.duplicateItem(tag) + + expect(duplicate.content.references).to.have.length(1) + expect(this.itemManager.items).to.have.length(3) + expect(this.itemManager.getDisplayableTags()).to.have.length(2) + }) + + it('adds duplicated item as a relationship to items referencing it', async function () { + const note = await this.createNote() + let tag = await this.createTag([note]) + const duplicateNote = await this.itemManager.duplicateItem(note) + expect(tag.content.references).to.have.length(1) + + tag = this.itemManager.findItem(tag.uuid) + const references = tag.content.references.map((ref) => ref.uuid) + expect(references).to.have.length(2) + expect(references).to.include(note.uuid, duplicateNote.uuid) + }) + + it('duplicates item with additional content', async function () { + const note = await this.itemManager.createItem(ContentType.Note, { + title: 'hello', + text: 'world', + }) + const duplicateNote = await this.itemManager.duplicateItem(note, false, { + title: 'hello (copy)', + }) + + expect(duplicateNote.title).to.equal('hello (copy)') + expect(duplicateNote.text).to.equal('world') + }) + }) + + it('set item deleted', async function () { + const note = await this.createNote() + await this.itemManager.setItemToBeDeleted(note) + + /** Items should never be mutated directly */ + expect(note.deleted).to.not.be.ok + + const latestVersion = this.payloadManager.findOne(note.uuid) + expect(latestVersion.deleted).to.equal(true) + expect(latestVersion.dirty).to.equal(true) + expect(latestVersion.content).to.not.be.ok + + /** Deleted items do not show up in item manager's public interface */ + expect(this.itemManager.items.length).to.equal(0) + expect(this.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('system smart views', async function () { + expect(this.itemManager.systemSmartViews.length).to.be.above(0) + }) + + it('find tag by title', async function () { + const tag = await this.createTag() + + expect(this.itemManager.findTagByTitle(tag.title)).to.be.ok + }) + + it('find tag by title should be case insensitive', async function () { + const tag = await this.createTag() + + expect(this.itemManager.findTagByTitle(tag.title.toUpperCase())).to.be.ok + }) + + it('find or create tag by title', async function () { + const title = 'foo' + + expect(await this.itemManager.findOrCreateTagByTitle(title)).to.be.ok + }) + + it('note count', async function () { + await this.createNote() + expect(this.itemManager.noteCount).to.equal(1) + }) + + it('trash', async function () { + const note = await this.createNote() + const versionTwo = await this.itemManager.changeItem(note, (mutator) => { + mutator.trashed = true + }) + + expect(this.itemManager.trashSmartView).to.be.ok + expect(versionTwo.trashed).to.equal(true) + expect(versionTwo.dirty).to.equal(true) + expect(versionTwo.content).to.be.ok + + expect(this.itemManager.items.length).to.equal(1) + expect(this.itemManager.trashedItems.length).to.equal(1) + + await this.itemManager.emptyTrash() + const versionThree = this.payloadManager.findOne(note.uuid) + expect(versionThree.deleted).to.equal(true) + expect(this.itemManager.trashedItems.length).to.equal(0) + }) + + it('remove all items from memory', async function () { + const observed = [] + this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { + observed.push({ changed, inserted, removed, ignored }) + }) + await this.createNote() + await this.itemManager.removeAllItemsFromMemory() + + const deletionEvent = observed[1] + expect(deletionEvent.removed[0].deleted).to.equal(true) + expect(this.itemManager.items.length).to.equal(0) + }) + + it('remove item locally', async function () { + const observed = [] + this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => { + observed.push({ changed, inserted, removed, ignored }) + }) + const note = await this.createNote() + await this.itemManager.removeItemLocally(note) + + expect(observed.length).to.equal(1) + expect(this.itemManager.findItem(note.uuid)).to.not.be.ok + }) + + it('emitting a payload from within observer should queue to end', async function () { + /** + * From within an item observer, we want to emit some changes and await them. + * We expect that the end result is that whatever was most recently emitted, + * is propagated to listeners after any pending observation events. That is, when you + * emit items, it should be done serially, so that emitting while you're emitting does + * not interrupt the current emission, but instead queues it. This is so that changes + * are not propagated out of order. + */ + const payload = Factory.createNotePayload() + const changedTitle = 'changed title' + let didEmit = false + let latestVersion + this.itemManager.addObserver(ContentType.Note, ({ changed, inserted }) => { + const all = changed.concat(inserted) + if (!didEmit) { + didEmit = true + const changedPayload = payload.copy({ + content: { + ...payload.content, + title: changedTitle, + }, + }) + this.itemManager.emitItemFromPayload(changedPayload) + } + latestVersion = all[0] + }) + await this.itemManager.emitItemFromPayload(payload) + expect(latestVersion.title).to.equal(changedTitle) + }) + + describe('searchTags', async function () { + it('should return tag with query matching title', async function () { + const tag = await this.itemManager.findOrCreateTagByTitle('tag') + + const results = this.itemManager.searchTags('tag') + expect(results).lengthOf(1) + expect(results[0].title).to.equal(tag.title) + }) + it('should return all tags with query partially matching title', async function () { + const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one') + const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two') + + const results = this.itemManager.searchTags('tag') + expect(results).lengthOf(2) + expect(results[0].title).to.equal(firstTag.title) + expect(results[1].title).to.equal(secondTag.title) + }) + it('should be case insensitive', async function () { + const tag = await this.itemManager.findOrCreateTagByTitle('Tag') + + const results = this.itemManager.searchTags('tag') + expect(results).lengthOf(1) + expect(results[0].title).to.equal(tag.title) + }) + it('should return tag with query matching delimiter separated component', async function () { + const tag = await this.itemManager.findOrCreateTagByTitle('parent.child') + + const results = this.itemManager.searchTags('child') + expect(results).lengthOf(1) + expect(results[0].title).to.equal(tag.title) + }) + it('should return tags with matching query including delimiter', async function () { + const tag = await this.itemManager.findOrCreateTagByTitle('parent.child') + + const results = this.itemManager.searchTags('parent.chi') + expect(results).lengthOf(1) + expect(results[0].title).to.equal(tag.title) + }) + + it('should return tags in natural order', async function () { + const firstTag = await this.itemManager.findOrCreateTagByTitle('tag 100') + const secondTag = await this.itemManager.findOrCreateTagByTitle('tag 2') + const thirdTag = await this.itemManager.findOrCreateTagByTitle('tag b') + const fourthTag = await this.itemManager.findOrCreateTagByTitle('tag a') + + const results = this.itemManager.searchTags('tag') + expect(results).lengthOf(4) + expect(results[0].title).to.equal(secondTag.title) + expect(results[1].title).to.equal(firstTag.title) + expect(results[2].title).to.equal(fourthTag.title) + expect(results[3].title).to.equal(thirdTag.title) + }) + + it('should not return tags associated with note', async function () { + const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one') + const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two') + + const note = await this.createNote() + await this.itemManager.changeItem(firstTag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + }) + + const results = this.itemManager.searchTags('tag', note) + expect(results).lengthOf(1) + expect(results[0].title).to.equal(secondTag.title) + }) + }) + + describe('smart views', async function () { + it('all view should not include archived notes by default', async function () { + const normal = await this.createNote() + + await this.itemManager.changeItem(normal, (mutator) => { + mutator.archived = true + }) + + this.itemManager.setPrimaryItemDisplayOptions({ + views: [this.itemManager.allNotesSmartView], + }) + + expect(this.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('archived view should not include trashed notes by default', async function () { + const normal = await this.createNote() + + await this.itemManager.changeItem(normal, (mutator) => { + mutator.archived = true + mutator.trashed = true + }) + + this.itemManager.setPrimaryItemDisplayOptions({ + views: [this.itemManager.archivedSmartView], + }) + + expect(this.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('trashed view should include archived notes by default', async function () { + const normal = await this.createNote() + + await this.itemManager.changeItem(normal, (mutator) => { + mutator.archived = true + mutator.trashed = true + }) + + this.itemManager.setPrimaryItemDisplayOptions({ + views: [this.itemManager.trashSmartView], + }) + + expect(this.itemManager.getDisplayableNotes().length).to.equal(1) + }) + }) + + describe('getSortedTagsForNote', async function () { + it('should return tags associated with a note in natural order', async function () { + const tags = [ + await this.itemManager.findOrCreateTagByTitle('tag 100'), + await this.itemManager.findOrCreateTagByTitle('tag 2'), + await this.itemManager.findOrCreateTagByTitle('tag b'), + await this.itemManager.findOrCreateTagByTitle('tag a'), + ] + + const note = await this.createNote() + + tags.map(async (tag) => { + await this.itemManager.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + }) + }) + + const results = this.itemManager.getSortedTagsForNote(note) + + expect(results).lengthOf(tags.length) + expect(results[0].title).to.equal(tags[1].title) + expect(results[1].title).to.equal(tags[0].title) + expect(results[2].title).to.equal(tags[3].title) + expect(results[3].title).to.equal(tags[2].title) + }) + }) + + describe('getTagParentChain', function () { + it('should return parent tags for a tag', async function () { + const [parent, child, grandchild, _other] = await Promise.all([ + this.itemManager.findOrCreateTagByTitle('parent'), + this.itemManager.findOrCreateTagByTitle('parent.child'), + this.itemManager.findOrCreateTagByTitle('parent.child.grandchild'), + this.itemManager.findOrCreateTagByTitle('some other tag'), + ]) + + await this.itemManager.setTagParent(parent, child) + await this.itemManager.setTagParent(child, grandchild) + + const results = this.itemManager.getTagParentChain(grandchild) + + expect(results).lengthOf(2) + expect(results[0].uuid).to.equal(parent.uuid) + expect(results[1].uuid).to.equal(child.uuid) + }) + }) +}) diff --git a/packages/snjs/mocha/key_params.test.js b/packages/snjs/mocha/key_params.test.js new file mode 100644 index 000000000..65d739998 --- /dev/null +++ b/packages/snjs/mocha/key_params.test.js @@ -0,0 +1,86 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('key params', function () { + this.timeout(Factory.TenSecondTimeout) + + before(async function () { + localStorage.clear() + }) + + after(async function () { + localStorage.clear() + }) + + it('extraneous parameters in key params should be ignored when ejecting', async function () { + const params = new SNRootKeyParams({ + identifier: 'foo', + pw_cost: 110000, + pw_nonce: 'bar', + pw_salt: 'salt', + version: '003', + origination: 'registration', + created: new Date().getTime(), + hash: '123', + foo: 'bar', + }) + const ejected = params.getPortableValue() + expect(ejected.hash).to.not.be.ok + expect(ejected.pw_cost).to.be.ok + expect(ejected.pw_nonce).to.be.ok + expect(ejected.pw_salt).to.be.ok + expect(ejected.version).to.be.ok + expect(ejected.origination).to.be.ok + expect(ejected.created).to.be.ok + expect(ejected.identifier).to.be.ok + }) + + describe('with missing version', function () { + it('should default to 002 if uses high cost', async function () { + const params = new SNRootKeyParams({ + identifier: 'foo', + pw_cost: 101000, + pw_nonce: 'bar', + pw_salt: 'salt', + }) + + expect(params.version).to.equal('002') + }) + + it('should default to 001 if uses low cost', async function () { + const params = new SNRootKeyParams({ + identifier: 'foo', + pw_cost: 60000, + pw_nonce: 'bar', + pw_salt: 'salt', + }) + + expect(params.version).to.equal('002') + }) + + it('should default to 002 if uses cost seen in both 001 and 002, but has no pw_nonce', async function () { + const params = new SNRootKeyParams({ + identifier: 'foo', + pw_cost: 60000, + pw_nonce: undefined, + pw_salt: 'salt', + }) + + expect(params.version).to.equal('002') + }) + + it('should default to 001 if uses cost seen in both 001 and 002, but is more likely a 001 cost', async function () { + const params = new SNRootKeyParams({ + identifier: 'foo', + pw_cost: 5000, + pw_nonce: 'bar', + pw_salt: 'salt', + }) + + expect(params.version).to.equal('001') + }) + }) +}) diff --git a/packages/snjs/mocha/key_recovery_service.test.js b/packages/snjs/mocha/key_recovery_service.test.js new file mode 100644 index 000000000..957cd4cbb --- /dev/null +++ b/packages/snjs/mocha/key_recovery_service.test.js @@ -0,0 +1,683 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('key recovery service', function () { + this.timeout(Factory.TwentySecondTimeout) + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(function () { + localStorage.clear() + }) + + afterEach(function () { + localStorage.clear() + }) + + it('when encountering an undecryptable items key, should recover through recovery wizard', async function () { + const namespace = Factory.randomString() + const context = await Factory.createAppContextWithFakeCrypto(namespace) + const unassociatedPassword = 'randfoo' + const unassociatedIdentifier = 'foorand' + + const application = context.application + await context.launch({ + receiveChallenge: (challenge) => { + application.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], unassociatedPassword), + ]) + }, + }) + + await context.register() + + const randomRootKey = await application.protocolService.createRootKey( + unassociatedIdentifier, + unassociatedPassword, + KeyParamsOrigination.Registration, + ) + const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey() + + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [randomItemsKey.payload], + key: randomRootKey, + }, + }) + + const errored = await application.protocolService.decryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [encrypted], + }, + }) + + expect(errored.errorDecrypting).to.equal(true) + + await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted) + + await context.resolveWhenKeyRecovered(errored.uuid) + + expect(application.items.findItem(errored.uuid).errorDecrypting).to.not.be.ok + + expect(application.syncService.isOutOfSync()).to.equal(false) + await context.deinit() + }) + + it('recovered keys with key params not matching servers should be synced if local root key does matches server', async function () { + /** + * This helps ensure server always has the most valid state, + * in case the recovery is being initiated from a server value in the first place + */ + const context = await Factory.createAppContextWithFakeCrypto() + const unassociatedPassword = 'randfoo' + const unassociatedIdentifier = 'foorand' + + const application = context.application + + await context.launch({ + receiveChallenge: (challenge) => { + application.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], unassociatedPassword), + ]) + }, + }) + await context.register() + + const randomRootKey = await application.protocolService.createRootKey( + unassociatedIdentifier, + unassociatedPassword, + KeyParamsOrigination.Registration, + ) + + const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey() + + await application.payloadManager.emitPayload( + randomItemsKey.payload.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() }), + PayloadEmitSource.LocalInserted, + ) + + await context.sync() + + const originalSyncTime = application.payloadManager.findOne(randomItemsKey.uuid).lastSyncEnd.getTime() + + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [randomItemsKey.payload], + key: randomRootKey, + }, + }) + + const errored = await application.protocolService.decryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [encrypted], + }, + }) + + await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted) + + const recoveryPromise = context.resolveWhenKeyRecovered(errored.uuid) + + await context.sync() + + await recoveryPromise + + expect(application.payloadManager.findOne(errored.uuid).lastSyncEnd.getTime()).to.be.above(originalSyncTime) + + await context.deinit() + }) + + it('recovered keys with key params not matching servers should not be synced if local root key does not match server', async function () { + /** + * Assume Application A has been through these states: + * 1. Registration + Items Key A + Root Key A + * 2. Password change + Items Key B + Root Key B + * 3. Password change + Items Key C + Root Key C + Failure to correctly re-encrypt Items Key A and B with Root Key C + * + * Application B is not correctly in sync, and is only at State 1 (Registration + Items Key A) + * + * Application B receives Items Key B of Root Key B but for whatever reason ignores Items Key C of Root Key C. + * + * When it recovers Items Key B, it should not re-upload it to the server, because Application B's Root Key is not + * the current account's root key. + */ + + const contextA = await Factory.createAppContextWithFakeCrypto() + await contextA.launch() + await contextA.register() + contextA.preventKeyRecoveryOfKeys() + + const contextB = await Factory.createAppContextWithFakeCrypto('app-b', contextA.email, contextA.password) + await contextB.launch() + await contextB.signIn() + + await contextA.changePassword('new-password-1') + const itemsKeyARootKeyB = contextA.itemsKeys[0] + const itemsKeyBRootKeyB = contextA.itemsKeys[1] + + contextA.disableSyncingOfItems([itemsKeyARootKeyB.uuid, itemsKeyBRootKeyB.uuid]) + await contextA.changePassword('new-password-2') + const itemsKeyCRootKeyC = contextA.itemsKeys[2] + + contextB.disableKeyRecoveryServerSignIn() + contextB.preventKeyRecoveryOfKeys([itemsKeyCRootKeyC.uuid]) + contextB.respondToAccountPasswordChallengeWith('new-password-1') + + const recoveryPromise = Promise.all([ + contextB.resolveWhenKeyRecovered(itemsKeyARootKeyB.uuid), + contextB.resolveWhenKeyRecovered(itemsKeyBRootKeyB.uuid), + ]) + + const observedDirtyItemUuids = [] + contextB.spyOnChangedItems((changed) => { + const dirty = changed.filter((i) => i.dirty) + extendArray(observedDirtyItemUuids, Uuids(dirty)) + }) + + await contextB.sync() + await recoveryPromise + + expect(observedDirtyItemUuids.includes(itemsKeyARootKeyB.uuid)).to.equal(false) + expect(observedDirtyItemUuids.includes(itemsKeyBRootKeyB.uuid)).to.equal(false) + + await contextA.deinit() + await contextB.deinit() + }) + + it('when encountering many undecryptable items key with same key params, should only prompt once', async function () { + const namespace = Factory.randomString() + const unassociatedPassword = 'randfoo' + const unassociatedIdentifier = 'foorand' + let totalPromptCount = 0 + + const context = await Factory.createAppContextWithFakeCrypto(namespace) + const application = context.application + const receiveChallenge = (challenge) => { + totalPromptCount++ + /** Give unassociated password when prompted */ + application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)]) + } + await application.prepareForLaunch({ receiveChallenge }) + await application.launch(true) + + await Factory.registerUserToApplication({ + application: application, + email: context.email, + password: context.password, + }) + + /** Create items key associated with a random root key */ + const randomRootKey = await application.protocolService.createRootKey( + unassociatedIdentifier, + unassociatedPassword, + KeyParamsOrigination.Registration, + ) + const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey() + const randomItemsKey2 = await application.protocolService.operatorManager.defaultOperator().createItemsKey() + + const encrypted = await application.protocolService.encryptSplit({ + usesRootKey: { + items: [randomItemsKey.payload, randomItemsKey2.payload], + key: randomRootKey, + }, + }) + + /** Attempt decryption and insert into rotation in errored state */ + const decrypted = await application.protocolService.decryptSplit({ + usesRootKeyWithKeyLookup: { + items: encrypted, + }, + }) + + await application.payloadManager.emitPayloads(decrypted, PayloadEmitSource.LocalInserted) + + /** Wait and allow recovery wizard to complete */ + await Factory.sleep(1.5) + + /** Should be decrypted now */ + expect(application.items.findItem(randomItemsKey.uuid).errorDecrypting).not.be.ok + expect(application.items.findItem(randomItemsKey2.uuid).errorDecrypting).not.be.ok + + expect(totalPromptCount).to.equal(1) + + expect(application.syncService.isOutOfSync()).to.equal(false) + await context.deinit() + }) + + it('when changing password on client B, client A should perform recovery flow', async function () { + const contextA = await Factory.createAppContextWithFakeCrypto() + await contextA.launch() + await contextA.register() + const originalItemsKey = contextA.application.items.getDisplayableItemsKeys()[0] + + const contextB = await Factory.createAppContextWithFakeCrypto( + 'another-namespace', + contextA.email, + contextA.password, + ) + + contextB.ignoreChallenges() + await contextB.launch() + await contextB.signIn() + + const newPassword = `${Math.random()}` + + const result = await contextB.application.changePassword(contextA.password, newPassword) + + expect(result.error).to.not.be.ok + expect(contextB.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2) + + const newItemsKey = contextB.application.items.getDisplayableItemsKeys().find((k) => k.uuid !== originalItemsKey.uuid) + + const note = await Factory.createSyncedNote(contextB.application) + + const recoveryPromise = contextA.resolveWhenKeyRecovered(newItemsKey.uuid) + + contextA.password = newPassword + + await contextA.sync(syncOptions) + await recoveryPromise + + /** Same previously errored key should now no longer be errored, */ + expect(contextA.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2) + for (const key of contextA.application.itemManager.getDisplayableItemsKeys()) { + expect(key.errorDecrypting).to.not.be.ok + } + + const aKey = await contextA.application.protocolService.getRootKey() + const bKey = await contextB.application.protocolService.getRootKey() + expect(aKey.compare(bKey)).to.equal(true) + + expect(contextA.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok + expect(contextB.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok + + expect(contextA.application.syncService.isOutOfSync()).to.equal(false) + expect(contextB.application.syncService.isOutOfSync()).to.equal(false) + + await contextA.deinit() + await contextB.deinit() + }).timeout(80000) + + it('when items key associated with item is errored, item should be marked waiting for key', async function () { + const namespace = Factory.randomString() + const newPassword = `${Math.random()}` + const contextA = await Factory.createAppContextWithFakeCrypto(namespace) + const appA = contextA.application + await appA.prepareForLaunch({ receiveChallenge: () => {} }) + await appA.launch(true) + + await Factory.registerUserToApplication({ + application: appA, + email: contextA.email, + password: contextA.password, + }) + + expect(appA.items.getItems(ContentType.ItemsKey).length).to.equal(1) + + /** Create simultaneous appB signed into same account */ + const appB = await Factory.createApplicationWithFakeCrypto('another-namespace') + await appB.prepareForLaunch({ receiveChallenge: () => {} }) + await appB.launch(true) + + await Factory.loginToApplication({ + application: appB, + email: contextA.email, + password: contextA.password, + }) + + /** Change password on appB */ + await appB.changePassword(contextA.password, newPassword) + const note = await Factory.createSyncedNote(appB) + await appB.sync.sync() + + /** We expect the item in appA to be errored at this point, but we do not want it to recover */ + await appA.sync.sync() + expect(appA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true) + + console.warn('Expecting exceptions below as we destroy app during key recovery') + await Factory.safeDeinit(appA) + await Factory.safeDeinit(appB) + + const recreatedAppA = await Factory.createApplicationWithFakeCrypto(namespace) + await recreatedAppA.prepareForLaunch({ receiveChallenge: () => {} }) + await recreatedAppA.launch(true) + + expect(recreatedAppA.payloadManager.findOne(note.uuid).errorDecrypting).to.equal(true) + expect(recreatedAppA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true) + await Factory.safeDeinit(recreatedAppA) + }) + + it('when client key params differ from server, and no matching items key exists to compare against, should perform sign in flow', async function () { + /** + * When a user changes password/email on client A, client B must update their root key to the new one. + * To do this, we can potentially avoid making a new sign in request (and creating a new session) by instead + * reading one of the undecryptable items key (which is potentially the new one that client A created). If the keyParams + * of that items key matches the servers, it means we can use those key params to compute our new local root key, + * instead of having to sign in. + */ + const unassociatedPassword = 'randfoo' + const context = await Factory.createAppContextWithFakeCrypto('some-namespace') + const application = context.application + + const receiveChallenge = (challenge) => { + const isKeyRecoveryPrompt = challenge.subheading?.includes(KeyRecoveryStrings.KeyRecoveryPasswordRequired) + application.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], isKeyRecoveryPrompt ? unassociatedPassword : context.password), + ]) + } + + await application.prepareForLaunch({ receiveChallenge }) + await application.launch(true) + await context.register() + + const correctRootKey = await application.protocolService.getRootKey() + + /** + * 1. Change our root key locally so that its keys params doesn't match the server's + * 2. Create an items key payload that is set to errorDecrypting, and which is encrypted + * with the incorrect root key, so that it cannot be used to validate the user's password + */ + + const unassociatedIdentifier = 'foorand' + + /** Create items key associated with a random root key */ + const randomRootKey = await application.protocolService.createRootKey( + unassociatedIdentifier, + unassociatedPassword, + KeyParamsOrigination.Registration, + ) + + const signInFunction = sinon.spy(application.keyRecoveryService, 'performServerSignIn') + + await application.protocolService.setRootKey(randomRootKey) + + const correctItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey() + + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [correctItemsKey.payload], + key: randomRootKey, + }, + }) + + const resolvePromise = Promise.all([ + context.awaitSignInEvent(), + context.resolveWhenKeyRecovered(correctItemsKey.uuid), + ]) + + await application.payloadManager.emitPayload( + encrypted.copy({ + errorDecrypting: true, + dirty: true, + }), + PayloadEmitSource.LocalInserted, + ) + + await context.sync() + + await resolvePromise + + expect(signInFunction.callCount).to.equal(1) + + const clientRootKey = await application.protocolService.getRootKey() + expect(clientRootKey.compare(correctRootKey)).to.equal(true) + + const decryptedKey = application.items.findItem(correctItemsKey.uuid) + expect(decryptedKey).to.be.ok + expect(decryptedKey.content.itemsKey).to.equal(correctItemsKey.content.itemsKey) + + expect(application.syncService.isOutOfSync()).to.equal(false) + await context.deinit() + }) + + it(`when encountering an items key that cannot be decrypted for which we already have a decrypted value, + it should be emitted as ignored`, async function () { + const context = await Factory.createAppContextWithFakeCrypto() + const application = context.application + await context.launch() + await context.register() + + /** Create and emit errored encrypted items key payload */ + const itemsKey = await application.protocolService.getSureDefaultItemsKey() + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [itemsKey.payload], + }, + }) + + const newUpdated = new Date() + const errored = encrypted.copy({ + content: '004:...', + errorDecrypting: true, + updated_at: newUpdated, + }) + + await context.receiveServerResponse({ retrievedItems: [errored.ejected()] }) + + /** Our current items key should not be overwritten */ + const currentItemsKey = application.items.findItem(itemsKey.uuid) + expect(currentItemsKey.errorDecrypting).to.not.be.ok + expect(currentItemsKey.itemsKey).to.equal(itemsKey.itemsKey) + + /** The timestamp of our current key should be updated however so we do not enter out of sync state */ + expect(currentItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime()) + + expect(application.syncService.isOutOfSync()).to.equal(false) + + await context.deinit() + }) + + it(`ignored key payloads should be added to undecryptables and recovered`, async function () { + const context = await Factory.createAppContextWithFakeCrypto() + const application = context.application + await context.launch() + await context.register() + + const itemsKey = await application.protocolService.getSureDefaultItemsKey() + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [itemsKey.payload], + }, + }) + + const newUpdated = new Date() + const errored = encrypted.copy({ + errorDecrypting: true, + updated_at: newUpdated, + }) + + await application.payloadManager.emitDeltaEmit({ + emits: [], + ignored: [errored], + source: PayloadEmitSource.RemoteRetrieved, + }) + + await context.resolveWhenKeyRecovered(itemsKey.uuid) + + const latestItemsKey = application.items.findItem(itemsKey.uuid) + + expect(latestItemsKey.errorDecrypting).to.not.be.ok + expect(latestItemsKey.itemsKey).to.equal(itemsKey.itemsKey) + expect(latestItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime()) + expect(application.syncService.isOutOfSync()).to.equal(false) + + await context.deinit() + }) + + it('application should prompt to recover undecryptables on launch', async function () { + const namespace = Factory.randomString() + const context = await Factory.createAppContextWithFakeCrypto(namespace) + const application = context.application + await context.launch() + await context.register() + + /** Create and emit errored encrypted items key payload */ + const itemsKey = await application.protocolService.getSureDefaultItemsKey() + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [itemsKey.payload], + }, + }) + + context.disableKeyRecovery() + + await application.payloadManager.emitDeltaEmit({ + emits: [], + ignored: [ + encrypted.copy({ + errorDecrypting: true, + }), + ], + source: PayloadEmitSource.RemoteRetrieved, + }) + + /** Allow enough time to persist to disk, but not enough to complete recovery wizard */ + console.warn('Expecting some error below because we are destroying app in the middle of processing.') + + await Factory.sleep(0.1) + + expect(application.syncService.isOutOfSync()).to.equal(false) + + await context.deinit() + + const recreatedContext = await Factory.createAppContextWithFakeCrypto(namespace, context.email, context.password) + + const recreatedApp = recreatedContext.application + + const promise = recreatedContext.resolveWhenKeyRecovered(itemsKey.uuid) + + await recreatedContext.launch() + + await promise + + await Factory.safeDeinit(recreatedApp) + }) + + it('when encountering an undecryptable 003 items key, should recover through recovery wizard', async function () { + const namespace = Factory.randomString() + const unassociatedPassword = 'randfoo' + const unassociatedIdentifier = 'foorand' + + const context = await Factory.createAppContextWithFakeCrypto(namespace) + const application = context.application + const receiveChallenge = (challenge) => { + /** Give unassociated password when prompted */ + application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)]) + } + await application.prepareForLaunch({ receiveChallenge }) + await application.launch(true) + + await Factory.registerOldUser({ + application: application, + email: context.email, + password: context.password, + version: ProtocolVersion.V003, + }) + + /** Create items key associated with a random root key */ + const randomRootKey = await application.protocolService.createRootKey( + unassociatedIdentifier, + unassociatedPassword, + KeyParamsOrigination.Registration, + ProtocolVersion.V003, + ) + const randomItemsKey = await application.protocolService.operatorManager + .operatorForVersion(ProtocolVersion.V003) + .createItemsKey() + + const encrypted = await application.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [randomItemsKey.payload], + key: randomRootKey, + }, + }) + + /** Attempt decryption and insert into rotation in errored state */ + const decrypted = await application.protocolService.decryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [encrypted], + }, + }) + /** Expect to be errored */ + expect(decrypted.errorDecrypting).to.equal(true) + + /** Insert into rotation */ + await application.payloadManager.emitPayload(decrypted, PayloadEmitSource.LocalInserted) + + /** Wait and allow recovery wizard to complete */ + await Factory.sleep(0.3) + + /** Should be decrypted now */ + expect(application.items.findItem(encrypted.uuid).errorDecrypting).to.not.be.ok + + expect(application.syncService.isOutOfSync()).to.equal(false) + await context.deinit() + }) + + it('when replacing root key, new root key should be set before items key are re-saved to disk', async function () { + const contextA = await Factory.createAppContextWithFakeCrypto() + await contextA.launch() + await contextA.register() + + const newPassword = 'new-password' + + /** Create simultaneous appB signed into same account */ + const contextB = await Factory.createAppContextWithFakeCrypto( + 'another-namespace', + contextA.email, + contextA.password, + ) + + contextB.ignoreChallenges() + await contextB.launch() + await contextB.signIn() + const appB = contextB.application + + /** Change password on appB */ + const result = await appB.changePassword(contextA.password, newPassword) + expect(result.error).to.not.be.ok + contextA.password = newPassword + await appB.sync.sync() + + const newDefaultKey = appB.protocolService.getSureDefaultItemsKey() + + const encrypted = await appB.protocolService.encryptSplitSingle({ + usesRootKeyWithKeyLookup: { + items: [newDefaultKey.payload], + }, + }) + + /** Insert foreign items key into appA, which shouldn't be able to decrypt it yet */ + const appA = contextA.application + await appA.payloadManager.emitPayload( + encrypted.copy({ + errorDecrypting: true, + }), + PayloadEmitSource.LocalInserted, + ) + + await Factory.awaitFunctionInvokation(appA.keyRecoveryService, 'handleDecryptionOfAllKeysMatchingCorrectRootKey') + + /** Stored version of items key should use new root key */ + const stored = (await appA.deviceInterface.getAllRawDatabasePayloads(appA.identifier)).find( + (payload) => payload.uuid === newDefaultKey.uuid, + ) + const storedParams = await appA.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(stored)) + + const correctStored = (await appB.deviceInterface.getAllRawDatabasePayloads(appB.identifier)).find( + (payload) => payload.uuid === newDefaultKey.uuid, + ) + + const correctParams = await appB.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(correctStored)) + + expect(storedParams).to.eql(correctParams) + + await contextA.deinit() + await contextB.deinit() + }).timeout(80000) +}) diff --git a/packages/snjs/mocha/keys.test.js b/packages/snjs/mocha/keys.test.js new file mode 100644 index 000000000..222f483ff --- /dev/null +++ b/packages/snjs/mocha/keys.test.js @@ -0,0 +1,845 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import * as Utils from './lib/Utils.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('keys', function () { + this.timeout(Factory.TwentySecondTimeout) + + beforeEach(async function () { + localStorage.clear() + + this.context = await Factory.createAppContext() + await this.context.launch() + + this.application = this.context.application + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + if (!this.application.dealloced) { + await Factory.safeDeinit(this.application) + } + + this.application = undefined + localStorage.clear() + }) + + it('should not have root key by default', async function () { + expect(await this.application.protocolService.getRootKey()).to.not.be.ok + }) + + it('validates content types requiring root encryption', function () { + expect(ContentTypeUsesRootKeyEncryption(ContentType.ItemsKey)).to.equal(true) + expect(ContentTypeUsesRootKeyEncryption(ContentType.EncryptedStorage)).to.equal(true) + expect(ContentTypeUsesRootKeyEncryption(ContentType.Item)).to.equal(false) + expect(ContentTypeUsesRootKeyEncryption(ContentType.Note)).to.equal(false) + }) + + it('generating export params with no account or passcode should produce encrypted payload', async function () { + /** Items key available by default */ + const payload = Factory.createNotePayload() + const processedPayload = CreateEncryptedLocalStorageContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(isEncryptedPayload(processedPayload)).to.equal(true) + }) + + it('has root key and one items key after registering user', async function () { + await Factory.registerUserToApplication({ application: this.application }) + expect(this.application.protocolService.getRootKey()).to.be.ok + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + }) + + it('changing root key with passcode should re-wrap root key', async function () { + const email = 'foo' + const password = 'bar' + const key = await this.application.protocolService.createRootKey(email, password, KeyParamsOrigination.Registration) + await this.application.protocolService.setRootKey(key) + Factory.handlePasswordChallenges(this.application, password) + await this.application.addPasscode(password) + + /** We should be able to decrypt wrapped root key with passcode */ + const wrappingKeyParams = await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams() + const wrappingKey = await this.application.protocolService.computeRootKey(password, wrappingKeyParams) + await this.application.protocolService.unwrapRootKey(wrappingKey).catch((error) => { + expect(error).to.not.be.ok + }) + + const newPassword = 'bar' + const newKey = await this.application.protocolService.createRootKey( + email, + newPassword, + KeyParamsOrigination.Registration, + ) + await this.application.protocolService.setRootKey(newKey, wrappingKey) + await this.application.protocolService.unwrapRootKey(wrappingKey).catch((error) => { + expect(error).to.not.be.ok + }) + }) + + it('items key should be encrypted with root key', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const itemsKey = await this.application.protocolService.getSureDefaultItemsKey() + const rootKey = await this.application.protocolService.getRootKey() + + /** Encrypt items key */ + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesRootKey: { + items: [itemsKey.payloadRepresentation()], + key: rootKey, + }, + }) + + /** Should not have an items_key_id */ + expect(encryptedPayload.items_key_id).to.not.be.ok + + /** Attempt to decrypt with root key. Should succeed. */ + const decryptedPayload = await this.application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [encryptedPayload], + key: rootKey, + }, + }) + + expect(decryptedPayload.errorDecrypting).to.not.be.ok + expect(decryptedPayload.content.itemsKey).to.equal(itemsKey.content.itemsKey) + }) + + it('should create random items key if no account and no passcode', async function () { + const itemsKeys = this.application.itemManager.getDisplayableItemsKeys() + expect(itemsKeys.length).to.equal(1) + const notePayload = Factory.createNotePayload() + + const dirtied = notePayload.copy({ + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + }) + await this.application.payloadManager.emitPayload(dirtied, PayloadEmitSource.LocalChanged) + await this.application.sync.sync() + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const rawNotePayload = rawPayloads.find((r) => r.content_type === ContentType.Note) + expect(typeof rawNotePayload.content).to.equal('string') + }) + + it('should keep offline created items key upon registration', async function () { + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + const originalItemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + await this.application.register(this.email, this.password) + + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + const newestItemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + expect(newestItemsKey.uuid).to.equal(originalItemsKey.uuid) + }) + + it('should use items key for encryption of note', async function () { + const keyToUse = await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption() + expect(keyToUse.content_type).to.equal(ContentType.ItemsKey) + }) + + it('encrypting an item should associate an items key to it', async function () { + const note = Factory.createNotePayload() + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note], + }, + }) + + const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + expect(itemsKey).to.be.ok + }) + + it('decrypt encrypted item with associated key', async function () { + const note = Factory.createNotePayload() + const title = note.content.title + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note], + }, + }) + + const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + expect(itemsKey).to.be.ok + + const decryptedPayload = await this.application.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [encryptedPayload], + }, + }) + + expect(decryptedPayload.content.title).to.equal(title) + }) + + it('decrypts items waiting for keys', async function () { + const notePayload = Factory.createNotePayload() + const title = notePayload.content.title + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [notePayload], + }, + }) + + const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload) + + await this.application.itemManager.removeItemLocally(itemsKey) + + const erroredPayload = await this.application.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [encryptedPayload], + }, + }) + + await this.application.itemManager.emitItemsFromPayloads([erroredPayload], PayloadEmitSource.LocalChanged) + + const note = this.application.itemManager.findAnyItem(notePayload.uuid) + expect(note.errorDecrypting).to.equal(true) + expect(note.waitingForKey).to.equal(true) + + const keyPayload = new DecryptedPayload(itemsKey.payload.ejected()) + await this.application.itemManager.emitItemsFromPayloads([keyPayload], PayloadEmitSource.LocalChanged) + + /** + * Sleeping is required to trigger asyncronous protocolService.decryptItemsWaitingForKeys, + * which occurs after keys are mapped above. + */ + await Factory.sleep(0.2) + + const updatedNote = this.application.itemManager.findItem(note.uuid) + + expect(updatedNote.errorDecrypting).to.not.be.ok + expect(updatedNote.waitingForKey).to.not.be.ok + expect(updatedNote.content.title).to.equal(title) + }) + + it('attempting to emit errored items key for which there exists a non errored master copy should ignore it', async function () { + await Factory.registerUserToApplication({ application: this.application }) + + const itemsKey = await this.application.protocolService.getSureDefaultItemsKey() + + expect(itemsKey.errorDecrypting).to.not.be.ok + + const errored = new EncryptedPayload({ + ...itemsKey.payload, + content: '004:...', + errorDecrypting: true, + }) + + const response = new ServerSyncResponse({ + data: { + retrieved_items: [errored.ejected()], + }, + }) + + await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + + const refreshedKey = this.application.payloadManager.findOne(itemsKey.uuid) + + expect(refreshedKey.errorDecrypting).to.not.be.ok + expect(refreshedKey.content.itemsKey).to.be.ok + }) + + it('generating export params with logged in account should produce encrypted payload', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const payload = Factory.createNotePayload() + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }) + expect(typeof encryptedPayload.content).to.equal('string') + expect(encryptedPayload.content.substring(0, 3)).to.equal(this.application.protocolService.getLatestVersion()) + }) + + it('When setting passcode, should encrypt items keys', async function () { + await this.application.addPasscode('foo') + const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === itemsKey.uuid) + const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload) + expect(itemsKeyPayload.enc_item_key).to.be.ok + }) + + it('items key encrypted payload should contain root key params', async function () { + await this.application.addPasscode('foo') + const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === itemsKey.uuid) + const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload) + const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004) + const comps = operator.deconstructEncryptedPayloadString(itemsKeyPayload.content) + const rawAuthenticatedData = comps.authenticatedData + const authenticatedData = await operator.stringToAuthenticatedData(rawAuthenticatedData) + const rootKeyParams = await this.application.protocolService.getRootKeyParams() + + expect(authenticatedData.kp).to.be.ok + expect(authenticatedData.kp).to.eql(rootKeyParams.getPortableValue()) + expect(authenticatedData.kp.origination).to.equal(KeyParamsOrigination.PasscodeCreate) + }) + + it('correctly validates local passcode', async function () { + const passcode = 'foo' + await this.application.addPasscode('foo') + expect((await this.application.protocolService.validatePasscode('wrong')).valid).to.equal(false) + expect((await this.application.protocolService.validatePasscode(passcode)).valid).to.equal(true) + }) + + it('signing into 003 account should delete latest offline items key and create 003 items key', async function () { + /** + * When starting the application it will create an items key with the latest protocol version (004). + * Upon signing into an 003 account, the application should delete any neverSynced items keys, + * and create a new default items key that is the default for a given protocol version. + */ + const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey() + const latestVersion = this.application.protocolService.getLatestVersion() + expect(defaultItemsKey.keyVersion).to.equal(latestVersion) + + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + + const itemsKeys = this.application.itemManager.getDisplayableItemsKeys() + expect(itemsKeys.length).to.equal(1) + const newestItemsKey = itemsKeys[0] + expect(newestItemsKey.keyVersion).to.equal(ProtocolVersion.V003) + const rootKey = await this.application.protocolService.getRootKey() + expect(newestItemsKey.itemsKey).to.equal(rootKey.masterKey) + expect(newestItemsKey.dataAuthenticationKey).to.equal(rootKey.dataAuthenticationKey) + }) + + it('reencrypts existing notes when logging into an 003 account', async function () { + await Factory.createManyMappedNotes(this.application, 10) + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + expect(this.application.itemManager.getDisplayableItemsKeys()[0].dirty).to.equal(false) + + /** Sign out and back in */ + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(10) + expect(this.application.payloadManager.invalidPayloads.length).to.equal(0) + }) + + it('When root key changes, all items keys must be re-encrypted', async function () { + const passcode = 'foo' + await this.application.addPasscode(passcode) + await Factory.createSyncedNote(this.application) + const itemsKeys = this.application.itemManager.getDisplayableItemsKeys() + expect(itemsKeys.length).to.equal(1) + const originalItemsKey = itemsKeys[0] + + const originalRootKey = await this.application.protocolService.getRootKey() + /** Expect that we can decrypt raw payload with current root key */ + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === originalItemsKey.uuid) + const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload) + const decrypted = await this.application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [itemsKeyPayload], + key: originalRootKey, + }, + }) + + expect(decrypted.errorDecrypting).to.not.be.ok + expect(decrypted.content).to.eql(originalItemsKey.content) + + /** Change passcode */ + Factory.handlePasswordChallenges(this.application, passcode) + await this.application.changePasscode('bar') + + const newRootKey = await this.application.protocolService.getRootKey() + expect(newRootKey).to.not.equal(originalRootKey) + expect(newRootKey.masterKey).to.not.equal(originalRootKey.masterKey) + + /** + * Expect that originalRootKey can no longer decrypt originalItemsKey + * as items key has been re-encrypted with new root key + */ + const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads() + const itemsKeyRawPayload2 = rawPayloads2.find((p) => p.uuid === originalItemsKey.uuid) + expect(itemsKeyRawPayload2.content).to.not.equal(itemsKeyRawPayload.content) + + const itemsKeyPayload2 = new EncryptedPayload(itemsKeyRawPayload2) + const decrypted2 = await this.application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [itemsKeyPayload2], + key: originalRootKey, + }, + }) + expect(decrypted2.errorDecrypting).to.equal(true) + + /** Should be able to decrypt with new root key */ + const decrypted3 = await this.application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [itemsKeyPayload2], + key: newRootKey, + }, + }) + expect(decrypted3.errorDecrypting).to.not.be.ok + }) + + it('changing account password should create new items key and encrypt items with that key', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + const itemsKeys = this.application.itemManager.getDisplayableItemsKeys() + expect(itemsKeys.length).to.equal(1) + const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey() + + const result = await this.application.changePassword(this.password, 'foobarfoo') + expect(result.error).to.not.be.ok + + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(2) + const newDefaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey() + expect(newDefaultItemsKey.uuid).to.not.equal(defaultItemsKey.uuid) + + const note = await Factory.createSyncedNote(this.application) + const payload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note.payload], + }, + }) + expect(payload.items_key_id).to.equal(newDefaultItemsKey.uuid) + }) + + it('changing account email should create new items key and encrypt items with that key', async function () { + const { application, email, password } = await Factory.createAndInitSimpleAppContext() + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + const itemsKeys = application.itemManager.getDisplayableItemsKeys() + expect(itemsKeys.length).to.equal(1) + const defaultItemsKey = application.protocolService.getSureDefaultItemsKey() + + const newEmail = UuidGenerator.GenerateUuid() + const result = await application.changeEmail(newEmail, password) + expect(result.error).to.not.be.ok + + expect(application.itemManager.getDisplayableItemsKeys().length).to.equal(2) + const newDefaultItemsKey = application.protocolService.getSureDefaultItemsKey() + expect(newDefaultItemsKey.uuid).to.not.equal(defaultItemsKey.uuid) + + const note = await Factory.createSyncedNote(application) + const payload = await application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note.payload], + }, + }) + expect(payload.items_key_id).to.equal(newDefaultItemsKey.uuid) + }) + + it('compares root keys', async function () { + const keyParams = {} + const a1 = await CreateNewRootKey({ + version: ProtocolVersion.V004, + masterKey: '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + serverPassword: 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + keyParams, + }) + const a2 = await CreateNewRootKey({ + version: ProtocolVersion.V004, + masterKey: '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE', + serverPassword: 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9', + keyParams, + }) + const b = await CreateNewRootKey({ + version: ProtocolVersion.V004, + masterKey: '2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824', + serverPassword: '486EA46224D1BB4FB680F34F7C9AD96A8F24EC88BE73EA8E5A6C65260E9CB8A7', + keyParams, + }) + + expect(a1.compare(a2)).to.equal(true) + expect(a2.compare(a1)).to.equal(true) + expect(a1.compare(b)).to.equal(false) + expect(b.compare(a1)).to.equal(false) + }) + + it('loading the keychain root key should also load its key params', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const rootKey = await this.application.protocolService.rootKeyEncryption.getRootKeyFromKeychain() + expect(rootKey.keyParams).to.be.ok + }) + + it('key params should be persisted separately and not as part of root key', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const rawKey = await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier) + expect(rawKey.keyParams).to.not.be.ok + const rawKeyParams = await this.application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(rawKeyParams).to.be.ok + }) + + it('persisted key params should exactly equal in memory rootKey.keyParams', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const rootKey = await this.application.protocolService.getRootKey() + const rawKeyParams = await this.application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(rootKey.keyParams.content).to.eql(rawKeyParams) + }) + + it('key params should have expected values', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const keyParamsObject = await this.application.protocolService.getRootKeyParams() + const keyParams = keyParamsObject.content + expect(keyParams.identifier).to.be.ok + expect(keyParams.pw_nonce).to.be.ok + expect(keyParams.version).to.equal(ProtocolVersion.V004) + expect(keyParams.created).to.be.ok + expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration) + expect(keyParams.email).to.not.be.ok + expect(keyParams.pw_cost).to.not.be.ok + expect(keyParams.pw_salt).to.not.be.ok + }) + + it('key params obtained when signing in should have created and origination', async function () { + const email = this.email + const password = this.password + await Factory.registerUserToApplication({ + application: this.application, + email, + password, + }) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await Factory.loginToApplication({ + application: this.application, + email, + password, + }) + const keyParamsObject = await this.application.protocolService.getRootKeyParams() + const keyParams = keyParamsObject.content + + expect(keyParams.created).to.be.ok + expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration) + }) + + it('key params for 003 account should still have origination and created', async function () { + /** origination and created are new properties since 004, but they can be added retroactively + * to previous versions. They are not essential to <= 003, but are for >= 004 */ + + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + const keyParamsObject = await this.application.protocolService.getRootKeyParams() + const keyParams = keyParamsObject.content + + expect(keyParams.created).to.be.ok + expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration) + }) + + it('encryption name should be dependent on key params version', async function () { + /** Register with 003 account */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + expect(await this.application.protocolService.getEncryptionDisplayName()).to.equal('AES-256') + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + /** Register with 004 account */ + await this.application.register(this.email + 'new', this.password) + expect(await this.application.protocolService.getEncryptionDisplayName()).to.equal('XChaCha20-Poly1305') + }) + + it('when launching app with no keychain but data, should present account recovery challenge', async function () { + /** + * On iOS (and perhaps other platforms where keychains are not included in device backups), + * when setting up a new device from restore, the keychain is deleted, but the data persists. + * We want to make sure we're prompting the user to re-authenticate their account. + */ + const id = this.application.identifier + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + /** Simulate empty keychain */ + await this.application.deviceInterface.clearRawKeychainValue() + + const recreatedApp = await Factory.createApplicationWithFakeCrypto(id) + let totalChallenges = 0 + const expectedChallenges = 1 + const receiveChallenge = (challenge) => { + totalChallenges++ + recreatedApp.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], this.password)]) + } + await recreatedApp.prepareForLaunch({ receiveChallenge }) + await recreatedApp.launch(true) + + expect(recreatedApp.protocolService.getRootKey()).to.be.ok + expect(totalChallenges).to.equal(expectedChallenges) + await Factory.safeDeinit(recreatedApp) + }) + + it('errored second client should not upload its items keys', async function () { + /** + * The original source of this issue was that when changing password on client A and syncing with B, + * the newly encrypted items key retrieved on B would be included as "ignored", so its timestamps + * would not be emitted, and thus the application would be in sync. The app would then download + * the items key independently, and make duplicates erroneously. + */ + const contextA = this.context + + const email = Utils.generateUuid() + const password = Utils.generateUuid() + await Factory.registerUserToApplication({ + application: contextA.application, + email, + password: password, + }) + + const contextB = await Factory.createAppContext({ email, password }) + await contextB.launch() + await contextB.signIn() + + contextA.ignoreChallenges() + contextB.ignoreChallenges() + + const newPassword = Utils.generateUuid() + + await contextA.application.userService.changeCredentials({ + currentPassword: password, + newPassword: newPassword, + origination: KeyParamsOrigination.PasswordChange, + }) + + await contextB.syncWithIntegrityCheck() + await contextA.syncWithIntegrityCheck() + + const clientAUndecryptables = contextA.application.keyRecoveryService.getUndecryptables() + const clientBUndecryptables = contextB.application.keyRecoveryService.getUndecryptables() + + expect(Object.keys(clientBUndecryptables).length).to.equal(1) + expect(Object.keys(clientAUndecryptables).length).to.equal(0) + }) + + describe('changing password on 003 client while signed into 004 client should', function () { + /** + * When an 004 client signs into 003 account, it creates a root key based items key. + * Then, if the 003 client changes its account password, and the 004 client + * re-authenticates, incorrect behavior (2.0.13) would be not to create a new root key based + * items key based on the new root key. The result would be that when the modified 003 + * items sync to the 004 client, it can't decrypt them with its existing items key + * because its based on the old root key. + */ + it.skip('add new items key', async function () { + this.timeout(Factory.TwentySecondTimeout * 3) + let oldClient = this.application + + /** Register an 003 account */ + await Factory.registerOldUser({ + application: oldClient, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + + /** Sign into account from another app */ + const newClient = await Factory.createAppWithRandNamespace() + await newClient.prepareForLaunch({ + receiveChallenge: (challenge) => { + /** Reauth session challenge */ + newClient.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], this.email), + CreateChallengeValue(challenge.prompts[1], this.password), + ]) + }, + }) + await newClient.launch() + + /** Change password through session manager directly instead of application, + * as not to create any items key (to simulate 003 client behavior) */ + const currentRootKey = await oldClient.protocolService.computeRootKey( + this.password, + await oldClient.protocolService.getRootKeyParams(), + ) + const operator = oldClient.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V003) + const newRootKey = await operator.createRootKey(this.email, this.password) + Object.defineProperty(oldClient.apiService, 'apiVersion', { + get: function () { + return '20190520' + }, + }) + + /** + * Sign in as late as possible on new client to prevent session timeouts + */ + await newClient.signIn(this.email, this.password) + + await oldClient.sessionManager.changeCredentials({ + currentServerPassword: currentRootKey.serverPassword, + newRootKey, + }) + + /** Re-authenticate on other app; allow challenge to complete */ + await newClient.sync.sync() + await Factory.sleep(1) + + /** Expect a new items key to be created based on the new root key */ + expect(newClient.itemManager.getDisplayableItemsKeys().length).to.equal(2) + + await Factory.safeDeinit(newClient) + await Factory.safeDeinit(oldClient) + }) + + it('add new items key from migration if pw change already happened', async function () { + /** Register an 003 account */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + + /** Change password through session manager directly instead of application, + * as not to create any items key (to simulate 003 client behavior) */ + const currentRootKey = await this.application.protocolService.computeRootKey( + this.password, + await this.application.protocolService.getRootKeyParams(), + ) + const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V003) + const newRootKey = await operator.createRootKey(this.email, this.password) + Object.defineProperty(this.application.apiService, 'apiVersion', { + get: function () { + return '20190520' + }, + }) + + /** Renew session to prevent timeouts */ + this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password) + + await this.application.sessionManager.changeCredentials({ + currentServerPassword: currentRootKey.serverPassword, + newRootKey, + }) + await this.application.protocolService.reencryptItemsKeys() + /** Note: this may result in a deadlock if features_service syncs and results in an error */ + await this.application.sync.sync({ awaitAll: true }) + + /** Relaunch application and expect new items key to be created */ + const identifier = this.application.identifier + /** Set to pre 2.0.15 version so migration runs */ + await this.application.deviceInterface.setRawStorageValue(`${identifier}-snjs_version`, '2.0.14') + await Factory.safeDeinit(this.application) + + const refreshedApp = await Factory.createApplicationWithFakeCrypto(identifier) + await Factory.initializeApplication(refreshedApp) + + /** Expect a new items key to be created based on the new root key */ + expect(refreshedApp.itemManager.getDisplayableItemsKeys().length).to.equal(2) + await Factory.safeDeinit(refreshedApp) + }) + }) + + it('importing 003 account backup, then registering for account, should properly reconcile keys', async function () { + /** + * When importing a backup of an 003 account into an offline state, ItemsKeys imported + * will have an updated_at value, which tell our protocol service that this key has been + * synced before, which sort of "lies" to the protocol service because now it thinks it doesnt + * need to create a new items key because one has already been synced with the account. + * The corrective action was to do a final check in protocolService.handleDownloadFirstSyncCompletion + * to ensure there exists an items key corresponding to the user's account version. + */ + await this.application.itemManager.removeAllItemsFromMemory() + expect(this.application.protocolService.getSureDefaultItemsKey()).to.not.be.ok + const protocol003 = new SNProtocolOperator003(new SNWebCrypto()) + const key = await protocol003.createItemsKey() + await this.application.itemManager.emitItemFromPayload( + key.payload.copy({ + content: { + ...key.payload.content, + isDefault: true, + }, + dirty: true, + /** Important to indicate that the key has been synced with a server */ + updated_at: Date.now(), + }), + ) + const defaultKey = this.application.protocolService.getSureDefaultItemsKey() + expect(defaultKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(defaultKey.uuid).to.equal(key.uuid) + await Factory.registerUserToApplication({ application: this.application }) + expect(await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption()).to.be.ok + }) + + it('having unsynced items keys should resync them upon download first sync completion', async function () { + await Factory.registerUserToApplication({ application: this.application }) + const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + await this.application.itemManager.emitItemFromPayload( + itemsKey.payload.copy({ + dirty: false, + updated_at: new Date(0), + deleted: false, + }), + ) + await this.application.syncService.sync({ + mode: SyncMode.DownloadFirst, + }) + const updatedKey = this.application.items.findItem(itemsKey.uuid) + expect(updatedKey.neverSynced).to.equal(false) + }) + + it('having key while offline then signing into account with key should only have 1 default items key', async function () { + const otherClient = await Factory.createInitAppWithFakeCrypto() + /** Invert order of keys */ + otherClient.itemManager.itemsKeyDisplayController.setDisplayOptions({ sortBy: 'dsc' }) + /** On client A, create account and note */ + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + await Factory.createSyncedNote(this.application) + const itemsKey = this.application.items.getItems(ContentType.ItemsKey)[0] + + /** Create another client and sign into account */ + await Factory.loginToApplication({ + application: otherClient, + email: this.email, + password: this.password, + }) + const defaultKeys = otherClient.protocolService.itemsEncryption.getItemsKeys().filter((key) => { + return key.isDefault + }) + expect(defaultKeys.length).to.equal(1) + + const rawPayloads = await otherClient.diskStorageService.getAllRawPayloads() + const notePayload = rawPayloads.find((p) => p.content_type === ContentType.Note) + + expect(notePayload.items_key_id).to.equal(itemsKey.uuid) + }) +}) diff --git a/packages/snjs/mocha/lib/AppContext.js b/packages/snjs/mocha/lib/AppContext.js new file mode 100644 index 000000000..912b21a25 --- /dev/null +++ b/packages/snjs/mocha/lib/AppContext.js @@ -0,0 +1,316 @@ +import FakeWebCrypto from './fake_web_crypto.js' +import * as Applications from './Applications.js' +import * as Utils from './Utils.js' +import { createNotePayload } from './Items.js' + +UuidGenerator.SetGenerator(new FakeWebCrypto().generateUUID) + +const MaximumSyncOptions = { + checkIntegrity: true, + awaitAll: true, +} + +export class AppContext { + constructor({ identifier, crypto, email, password, passcode } = {}) { + if (!identifier) { + identifier = `${Math.random()}` + } + + if (!email) { + email = UuidGenerator.GenerateUuid() + } + + if (!password) { + password = UuidGenerator.GenerateUuid() + } + + if (!passcode) { + passcode = 'mypasscode' + } + + this.identifier = identifier + this.crypto = crypto + this.email = email + this.password = password + this.passcode = passcode + } + + enableLogging() { + const syncService = this.application.syncService + const payloadManager = this.application.payloadManager + + syncService.getServiceName = () => { + return `${this.identifier}—SyncService` + } + payloadManager.getServiceName = () => { + return `${this.identifier}-PayloadManager` + } + + syncService.loggingEnabled = true + payloadManager.loggingEnabled = true + } + + async initialize() { + this.application = await Applications.createApplication( + this.identifier, + undefined, + undefined, + undefined, + this.crypto || new FakeWebCrypto(), + ) + } + + ignoreChallenges() { + this.ignoringChallenges = true + } + + resumeChallenges() { + this.ignoringChallenges = false + } + + disableIntegrityAutoHeal() { + this.application.syncService.emitOutOfSyncRemotePayloads = () => { + console.warn('Integrity self-healing is disabled for this test') + } + } + + disableKeyRecovery() { + this.application.keyRecoveryService.beginKeyRecovery = () => { + console.warn('Key recovery is disabled for this test') + } + } + + handleChallenge = (challenge) => { + if (this.ignoringChallenges) { + this.application.challengeService.cancelChallenge(challenge) + + return + } + + const responses = [] + + const accountPassword = this.passwordToUseForAccountPasswordChallenge || this.password + + for (const prompt of challenge.prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + responses.push(CreateChallengeValue(prompt, this.passcode)) + } else if (prompt.validation === ChallengeValidation.AccountPassword) { + responses.push(CreateChallengeValue(prompt, accountPassword)) + } else if (prompt.validation === ChallengeValidation.ProtectionSessionDuration) { + responses.push(CreateChallengeValue(prompt, 0)) + } else if (prompt.placeholder === 'Email') { + responses.push(CreateChallengeValue(prompt, this.email)) + } else if (prompt.placeholder === 'Password') { + responses.push(CreateChallengeValue(prompt, accountPassword)) + } else if (challenge.heading.includes('account password')) { + responses.push(CreateChallengeValue(prompt, accountPassword)) + } else { + console.log('Unhandled challenge', challenge) + throw Error(`Unhandled custom challenge in Factory.createAppContext`) + } + } + + this.application.submitValuesForChallenge(challenge, responses) + } + + signIn() { + const strict = false + const ephemeral = false + const mergeLocal = true + const awaitSync = true + return this.application.signIn(this.email, this.password, strict, ephemeral, mergeLocal, awaitSync) + } + + register() { + return this.application.register(this.email, this.password) + } + + receiveServerResponse({ retrievedItems }) { + const response = new ServerSyncResponse({ + data: { + retrieved_items: retrievedItems, + }, + }) + + return this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + } + + resolveWhenKeyRecovered(uuid) { + return new Promise((resolve) => { + this.application.keyRecoveryService.addEventObserver((_eventName, keys) => { + if (Uuids(keys).includes(uuid)) { + resolve() + } + }) + }) + } + + async awaitSignInEvent() { + return new Promise((resolve) => { + this.application.userService.addEventObserver((eventName) => { + if (eventName === AccountEvent.SignedInOrRegistered) { + resolve() + } + }) + }) + } + + async restart() { + const id = this.application.identifier + await Utils.safeDeinit(this.application) + const newApplication = await Applications.createAndInitializeApplication(id) + this.application = newApplication + return newApplication + } + + syncWithIntegrityCheck() { + return this.application.sync.sync({ checkIntegrity: true, awaitAll: true }) + } + + awaitNextSucessfulSync() { + return new Promise((resolve) => { + const removeObserver = this.application.syncService.addEventObserver((event) => { + if (event === SyncEvent.SyncCompletedWithAllItemsUploadedAndDownloaded) { + removeObserver() + resolve() + } + }) + }) + } + + awaitNextSyncEvent(eventName) { + return new Promise((resolve) => { + const removeObserver = this.application.syncService.addEventObserver((event, data) => { + if (event === eventName) { + removeObserver() + resolve(data) + } + }) + }) + } + + async launch({ awaitDatabaseLoad = true, receiveChallenge } = { awaitDatabaseLoad: true }) { + await this.application.prepareForLaunch({ + receiveChallenge: receiveChallenge || this.handleChallenge, + }) + await this.application.launch(awaitDatabaseLoad) + } + + async deinit() { + await Utils.safeDeinit(this.application) + } + + async sync(options) { + await this.application.sync.sync(options || { awaitAll: true }) + } + + async maximumSync() { + await this.sync(MaximumSyncOptions) + } + + async changePassword(newPassword) { + await this.application.changePassword(this.password, newPassword) + + this.password = newPassword + } + + findItem(uuid) { + return this.application.items.findItem(uuid) + } + + findPayload(uuid) { + return this.application.payloadManager.findPayload(uuid) + } + + get itemsKeys() { + return this.application.items.getDisplayableItemsKeys() + } + + disableSyncingOfItems(uuids) { + const originalImpl = this.application.items.getDirtyItems + + this.application.items.getDirtyItems = function () { + const result = originalImpl.apply(this) + + return result.filter((i) => !uuids.includes(i.uuid)) + } + } + + disableKeyRecoveryServerSignIn() { + this.application.keyRecoveryService.performServerSignIn = () => { + console.warn('application.keyRecoveryService.performServerSignIn has been stubbed with an empty implementation') + } + } + + preventKeyRecoveryOfKeys(ids) { + const originalImpl = this.application.keyRecoveryService.handleUndecryptableItemsKeys + + this.application.keyRecoveryService.handleUndecryptableItemsKeys = function (keys) { + const filtered = keys.filter((k) => !ids.includes(k.uuid)) + + originalImpl.apply(this, [filtered]) + } + } + + respondToAccountPasswordChallengeWith(password) { + this.passwordToUseForAccountPasswordChallenge = password + } + + spyOnChangedItems(callback) { + this.application.items.addObserver(ContentType.Any, ({ changed, unerrored }) => { + callback([...changed, ...unerrored]) + }) + } + + async createSyncedNote(title, text) { + const payload = createNotePayload(title, text) + const item = await this.application.items.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.items.setItemDirty(item) + await this.application.syncService.sync(MaximumSyncOptions) + const note = this.application.items.findItem(payload.uuid) + + return note + } + + async deleteItemAndSync(item) { + await this.application.mutator.deleteItem(item) + } + + async changeNoteTitle(note, title) { + return this.application.items.changeNote(note, (mutator) => { + mutator.title = title + }) + } + + async changeNoteTitleAndSync(note, title) { + await this.changeNoteTitle(note, title) + await this.sync() + + return this.findItem(note.uuid) + } + + findNoteByTitle(title) { + return this.application.items.getDisplayableNotes().find((note) => note.title === title) + } + + get noteCount() { + return this.application.items.getDisplayableNotes().length + } + + async createConflictedNotes(otherContext) { + const note = await this.createSyncedNote() + + await otherContext.sync() + + await this.changeNoteTitleAndSync(note, 'title-1') + + await otherContext.changeNoteTitleAndSync(note, 'title-2') + + await this.sync() + + return { + original: note, + conflict: this.findNoteByTitle('title-2'), + } + } +} diff --git a/packages/snjs/mocha/lib/Applications.js b/packages/snjs/mocha/lib/Applications.js new file mode 100644 index 000000000..d0d4fac53 --- /dev/null +++ b/packages/snjs/mocha/lib/Applications.js @@ -0,0 +1,71 @@ +import WebDeviceInterface from './web_device_interface.js' +import FakeWebCrypto from './fake_web_crypto.js' +import * as Defaults from './Defaults.js' + +export function createApplicationWithOptions({ identifier, environment, platform, host, crypto, device }) { + if (!device) { + device = new WebDeviceInterface() + device.environment = environment + } + + return new SNApplication({ + environment: environment || Environment.Web, + platform: platform || Platform.MacWeb, + deviceInterface: device, + crypto: crypto || new FakeWebCrypto(), + alertService: { + confirm: async () => true, + alert: async () => {}, + blockingDialog: () => () => {}, + }, + identifier: identifier || `${Math.random()}`, + defaultHost: host || Defaults.getDefaultHost(), + appVersion: Defaults.getAppVersion(), + webSocketUrl: Defaults.getDefaultWebSocketUrl(), + }) +} + +export function createApplication(identifier, environment, platform, host, crypto) { + return createApplicationWithOptions({ identifier, environment, platform, host, crypto }) +} + +export function createApplicationWithFakeCrypto(identifier, environment, platform, host) { + return createApplication(identifier, environment, platform, host, new FakeWebCrypto()) +} + +export function createApplicationWithRealCrypto(identifier, environment, platform, host) { + return createApplication(identifier, environment, platform, host, new SNWebCrypto()) +} + +export async function createAppWithRandNamespace(environment, platform) { + const namespace = Math.random().toString(36).substring(2, 15) + return createApplication(namespace, environment, platform) +} + +export async function createInitAppWithFakeCrypto(environment, platform) { + const namespace = Math.random().toString(36).substring(2, 15) + return createAndInitializeApplication(namespace, environment, platform, undefined, new FakeWebCrypto()) +} + +export async function createInitAppWithRealCrypto(environment, platform) { + const namespace = Math.random().toString(36).substring(2, 15) + return createAndInitializeApplication(namespace, environment, platform, undefined, new SNWebCrypto()) +} + +export async function createAndInitializeApplication(namespace, environment, platform, host, crypto) { + const application = createApplication(namespace, environment, platform, host, crypto) + await initializeApplication(application) + return application +} + +export async function initializeApplication(application) { + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + console.warn('Factory received potentially unhandled challenge', challenge) + if (challenge.reason !== ChallengeReason.Custom) { + throw Error("Factory application shouldn't have challenges") + } + }, + }) + await application.launch(true) +} diff --git a/packages/snjs/mocha/lib/Defaults.js b/packages/snjs/mocha/lib/Defaults.js new file mode 100644 index 000000000..197c2c620 --- /dev/null +++ b/packages/snjs/mocha/lib/Defaults.js @@ -0,0 +1,15 @@ +export function getDefaultHost() { + return 'http://localhost:3123' +} + +export function getDefaultMockedEventServiceUrl() { + return 'http://localhost:3124' +} + +export function getDefaultWebSocketUrl() { + return undefined +} + +export function getAppVersion() { + return '1.2.3' +} diff --git a/packages/snjs/mocha/lib/Items.js b/packages/snjs/mocha/lib/Items.js new file mode 100644 index 000000000..09c4e9841 --- /dev/null +++ b/packages/snjs/mocha/lib/Items.js @@ -0,0 +1,72 @@ +import * as Utils from './Utils.js' + +const MaximumSyncOptions = { + checkIntegrity: true, + awaitAll: true, +} + +export function createItemParams(contentType) { + const params = { + uuid: Utils.generateUuid(), + content_type: contentType, + content: { + title: 'hello', + text: 'world', + }, + } + return params +} + +export function createNoteParams({ title, text, dirty = true } = {}) { + const params = { + uuid: Utils.generateUuid(), + content_type: ContentType.Note, + dirty: dirty, + dirtyIndex: dirty ? getIncrementedDirtyIndex() : undefined, + content: FillItemContent({ + title: title || 'hello', + text: text || 'world', + }), + } + return params +} + +export function createTagParams({ title, dirty = true, uuid = undefined } = {}) { + const params = { + uuid: uuid || Utils.generateUuid(), + content_type: ContentType.Tag, + dirty: dirty, + dirtyIndex: dirty ? getIncrementedDirtyIndex() : undefined, + content: FillItemContent({ + title: title || 'thoughts', + }), + } + return params +} + +export function createRelatedNoteTagPairPayload({ noteTitle, noteText, tagTitle, dirty = true } = {}) { + const noteParams = createNoteParams({ + title: noteTitle, + text: noteText, + dirty, + }) + const tagParams = createTagParams({ title: tagTitle, dirty }) + tagParams.content.references = [ + { + uuid: noteParams.uuid, + content_type: noteParams.content_type, + }, + ] + noteParams.content.references = [] + return [new DecryptedPayload(noteParams), new DecryptedPayload(tagParams)] +} + +export async function createSyncedNoteWithTag(application) { + const payloads = createRelatedNoteTagPairPayload() + await application.itemManager.emitItemsFromPayloads(payloads) + return application.sync.sync(MaximumSyncOptions) +} + +export function createNotePayload(title, text = undefined, dirty = true) { + return new DecryptedPayload(createNoteParams({ title, text, dirty })) +} diff --git a/packages/snjs/mocha/lib/Utils.js b/packages/snjs/mocha/lib/Utils.js new file mode 100644 index 000000000..5c6bcb830 --- /dev/null +++ b/packages/snjs/mocha/lib/Utils.js @@ -0,0 +1,34 @@ +import FakeWebCrypto from './fake_web_crypto.js' + +export async function safeDeinit(application) { + if (application.dealloced) { + console.warn( + 'Attempting to deinit already deinited application. Check the test case to find where you are double deiniting.', + ) + return + } + + await application.diskStorageService.awaitPersist() + + /** Limit waiting to 1s */ + await Promise.race([sleep(1), application.syncService?.awaitCurrentSyncs()]) + + await application.prepareForDeinit() + + application.deinit(DeinitMode.Soft, DeinitSource.SignOut) +} + +export async function sleep(seconds) { + console.warn(`Test sleeping for ${seconds}s`) + + return new Promise((resolve, reject) => { + setTimeout(function () { + resolve() + }, seconds * 1000) + }) +} + +export function generateUuid() { + const crypto = new FakeWebCrypto() + return crypto.generateUUID() +} diff --git a/packages/snjs/mocha/lib/factory.js b/packages/snjs/mocha/lib/factory.js new file mode 100644 index 000000000..b62673491 --- /dev/null +++ b/packages/snjs/mocha/lib/factory.js @@ -0,0 +1,492 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import FakeWebCrypto from './fake_web_crypto.js' +import { AppContext } from './AppContext.js' +import * as Applications from './Applications.js' +import * as Defaults from './Defaults.js' +import * as Utils from './Utils.js' +import { createItemParams, createNoteParams, createTagParams } from './Items.js' + +export const TenSecondTimeout = 10_000 +export const TwentySecondTimeout = 20_000 +export const ThirtySecondTimeout = 30_000 + +export const syncOptions = { + checkIntegrity: true, + awaitAll: true, +} + +export async function createAndInitSimpleAppContext( + { registerUser, environment } = { + registerUser: false, + environment: Environment.Web, + }, +) { + const application = await createInitAppWithFakeCrypto(environment) + const email = UuidGenerator.GenerateUuid() + const password = UuidGenerator.GenerateUuid() + const newPassword = randomString() + + if (registerUser) { + await registerUserToApplication({ + application, + email, + password, + }) + } + + return { + application, + email, + password, + newPassword, + } +} + +export async function createAppContextWithFakeCrypto(identifier, email, password) { + return createAppContext({ identifier, crypto: new FakeWebCrypto(), email, password }) +} + +export async function createAppContextWithRealCrypto(identifier) { + return createAppContext({ identifier, crypto: new SNWebCrypto() }) +} + +export async function createAppContext({ identifier, crypto, email, password } = {}) { + const context = new AppContext({ identifier, crypto, email, password }) + await context.initialize() + return context +} + +export function disableIntegrityAutoHeal(application) { + application.syncService.emitOutOfSyncRemotePayloads = () => { + console.warn('Integrity self-healing is disabled for this test') + } +} + +export async function safeDeinit(application) { + return Utils.safeDeinit(application) +} + +export function getDefaultHost() { + return Defaults.getDefaultHost() +} + +export async function publishMockedEvent(eventType, eventPayload) { + await fetch(`${Defaults.getDefaultMockedEventServiceUrl()}/events`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + eventType, + eventPayload, + }), + }) +} + +export function createApplicationWithFakeCrypto(identifier, environment, platform, host) { + return Applications.createApplicationWithFakeCrypto(identifier, environment, platform, host) +} + +export function createApplicationWithRealCrypto(identifier, environment, platform, host) { + return Applications.createApplicationWithRealCrypto(identifier, environment, platform, host) +} + +export async function createAppWithRandNamespace(environment, platform) { + return Applications.createAppWithRandNamespace(environment, platform) +} + +export async function createInitAppWithFakeCrypto(environment, platform) { + return Applications.createInitAppWithFakeCrypto(environment, platform) +} + +export async function createInitAppWithFakeCryptoWithOptions({ environment, platform, identifier }) { + const application = Applications.createApplicationWithOptions({ identifier, environment, platform }) + await Applications.initializeApplication(application) + return application +} + +export async function createInitAppWithRealCrypto(environment, platform) { + return Applications.createInitAppWithRealCrypto(environment, platform) +} + +export async function createAndInitializeApplication(namespace, environment, platform, host, crypto) { + return Applications.createAndInitializeApplication(namespace, environment, platform, host, crypto) +} + +export async function initializeApplication(application) { + return Applications.initializeApplication(application) +} + +export function registerUserToApplication({ application, email, password, ephemeral, mergeLocal = true }) { + if (!email) email = Utils.generateUuid() + if (!password) password = Utils.generateUuid() + return application.register(email, password, ephemeral, mergeLocal) +} + +export async function setOldVersionPasscode({ application, passcode, version }) { + const identifier = await application.protocolService.crypto.generateUUID() + const operator = application.protocolService.operatorManager.operatorForVersion(version) + const key = await operator.createRootKey(identifier, passcode, KeyParamsOrigination.PasscodeCreate) + await application.protocolService.setNewRootKeyWrapper(key) + await application.userService.rewriteItemsKeys() + await application.syncService.sync(syncOptions) +} + +/** + * Using application.register will always use latest version of protocol. + * To use older version, use this method. + */ +export async function registerOldUser({ application, email, password, version }) { + if (!email) email = Utils.generateUuid() + if (!password) password = Utils.generateUuid() + const operator = application.protocolService.operatorManager.operatorForVersion(version) + const accountKey = await operator.createRootKey(email, password, KeyParamsOrigination.Registration) + + const response = await application.userApiService.register(email, accountKey.serverPassword, accountKey.keyParams) + /** Mark all existing items as dirty. */ + await application.itemManager.changeItems(application.itemManager.items, (m) => { + m.dirty = true + }) + await application.sessionManager.handleSuccessAuthResponse(response, accountKey) + application.notifyEvent(ApplicationEvent.SignedIn) + await application.syncService.sync({ + mode: SyncMode.DownloadFirst, + ...syncOptions, + }) + await application.protocolService.decryptErroredPayloads() +} + +export function createStorageItemPayload(contentType) { + return new DecryptedPayload(createItemParams(contentType)) +} + +export function createNotePayload(title, text = undefined, dirty = true) { + return new DecryptedPayload(createNoteParams({ title, text, dirty })) +} + +export function createNote(title, text = undefined, dirty = true) { + return new SNNote(new DecryptedPayload(createNoteParams({ title, text, dirty }))) +} + +export function createStorageItemTagPayload(tagParams = {}) { + return new DecryptedPayload(createTagParams(tagParams)) +} + +export function itemToStoragePayload(item) { + return new DecryptedPayload(item) +} + +export function createMappedNote(application, title, text, dirty = true) { + const payload = createNotePayload(title, text, dirty) + return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) +} + +export async function createMappedTag(application, tagParams = {}) { + const payload = createStorageItemTagPayload(tagParams) + return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) +} + +export async function createSyncedNote(application, title, text) { + const payload = createNotePayload(title, text) + const item = await application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await application.itemManager.setItemDirty(item) + await application.syncService.sync(syncOptions) + const note = application.items.findItem(payload.uuid) + return note +} + +export async function getStoragePayloadsOfType(application, type) { + const rawPayloads = await application.diskStorageService.getAllRawPayloads() + return rawPayloads + .filter((rp) => rp.content_type === type) + .map((rp) => { + return new CreatePayload(rp) + }) +} + +export async function createManyMappedNotes(application, count) { + const createdNotes = [] + for (let i = 0; i < count; i++) { + const note = await createMappedNote(application) + await application.itemManager.setItemDirty(note) + createdNotes.push(note) + } + return createdNotes +} + +export async function loginToApplication({ + application, + email, + password, + ephemeral, + strict = false, + mergeLocal = true, + awaitSync = true, +}) { + return application.signIn(email, password, strict, ephemeral, mergeLocal, awaitSync) +} + +export async function awaitFunctionInvokation(object, functionName) { + return new Promise((resolve) => { + const original = object[functionName] + object[functionName] = async function () { + const result = original.apply(this, arguments) + resolve(result) + return result + } + }) +} + +/** + * Signing out of an application deinits it. + * A new one must be created. + */ +export async function signOutApplicationAndReturnNew(application) { + const isRealCrypto = application.crypto instanceof SNWebCrypto + await application.user.signOut() + if (isRealCrypto) { + return createInitAppWithRealCrypto() + } else { + return createInitAppWithFakeCrypto() + } +} + +export async function signOutAndBackIn(application, email, password) { + const isRealCrypto = application.crypto instanceof SNWebCrypto + await application.user.signOut() + const newApplication = isRealCrypto ? await createInitAppWithRealCrypto() : await createInitAppWithFakeCrypto() + await this.loginToApplication({ + application: newApplication, + email, + password, + }) + return newApplication +} + +export async function restartApplication(application) { + const id = application.identifier + await safeDeinit(application) + const newApplication = await createAndInitializeApplication(id) + return newApplication +} + +export async function storagePayloadCount(application) { + const payloads = await application.diskStorageService.getAllRawPayloads() + return payloads.length +} + +/** + * The number of seconds between changes before a server creates a new revision. + * Controlled via docker/syncing-server-js.env + */ +export const ServerRevisionFrequency = 1.1 + +export function yesterday() { + return new Date(new Date().setDate(new Date().getDate() - 1)) +} + +export function dateToMicroseconds(date) { + return date.getTime() * 1_000 +} + +export function tomorrow() { + return new Date(new Date().setDate(new Date().getDate() + 1)) +} + +export async function sleep(seconds) { + return Utils.sleep(seconds) +} + +export function shuffleArray(a) { + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] + } + return a +} + +export function randomString(length = 10) { + let result = '' + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const charactersLength = characters.length + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)) + } + return result +} + +export function generateUuidish() { + return this.randomString(32) +} + +export function randomArrayValue(array) { + return array[Math.floor(Math.random() * array.length)] +} + +export async function expectThrowsAsync(method, errorMessage) { + let error = null + try { + await method() + } catch (err) { + error = err + } + const expect = chai.expect + expect(error).to.be.an('Error') + if (errorMessage) { + expect(error.message) + .to.be.a('string') + .and.satisfy((msg) => msg.startsWith(errorMessage)) + } +} + +export function ignoreChallenges(application) { + application.setLaunchCallback({ + receiveChallenge() { + /** no-op */ + }, + }) +} + +export function handlePasswordChallenges(application, password) { + application.setLaunchCallback({ + receiveChallenge: (challenge) => { + const values = challenge.prompts.map((prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.ProtectionSessionDuration + ? UnprotectedAccessSecondsDuration.OneMinute + : password, + ), + ) + application.submitValuesForChallenge(challenge, values) + }, + }) +} + +export async function createTags(application, hierarchy, parent = undefined, resultAccumulator = undefined) { + const result = resultAccumulator || {} + + const promises = Object.entries(hierarchy).map(async ([key, value]) => { + let tag = await application.mutator.findOrCreateTag(key) + + result[key] = tag + + if (parent) { + await application.mutator.setTagParent(parent, tag) + } + + if (value === true) { + return + } + + await createTags(application, value, tag, result) + }) + + await Promise.all(promises) + + return result +} + +export function pinNote(application, note) { + return application.mutator.changeItem(note, (mutator) => { + mutator.pinned = true + }) +} + +export async function insertItemWithOverride(application, contentType, content, needsSync = false, errorDecrypting) { + const item = await application.itemManager.createItem(contentType, content, needsSync) + + if (errorDecrypting) { + const encrypted = new EncryptedPayload({ + ...item.payload.ejected(), + content: '004:...', + errorDecrypting, + }) + + await application.itemManager.emitItemFromPayload(encrypted) + } else { + const decrypted = new DecryptedPayload({ + ...item.payload.ejected(), + }) + await application.itemManager.emitItemFromPayload(decrypted) + } + + return application.itemManager.findAnyItem(item.uuid) +} + +export async function alternateUuidForItem(application, uuid) { + const item = application.itemManager.findItem(uuid) + const payload = new DecryptedPayload(item) + const results = await PayloadsByAlternatingUuid(payload, application.payloadManager.getMasterCollection()) + await application.payloadManager.emitPayloads(results, PayloadEmitSource.LocalChanged) + await application.syncService.persistPayloads(results) + return application.itemManager.findItem(results[0].uuid) +} + +export async function markDirtyAndSyncItem(application, itemToLookupUuidFor) { + const item = application.itemManager.findItem(itemToLookupUuidFor.uuid) + if (!item) { + throw Error('Attempting to save non-inserted item') + } + if (!item.dirty) { + await application.itemManager.changeItem(item, undefined, MutationType.NoUpdateUserTimestamps) + } + await application.sync.sync() +} + +export async function changePayloadTimeStampAndSync(application, payload, timestamp, contentOverride, syncOptions) { + await changePayloadTimeStamp(application, payload, timestamp, contentOverride) + + await application.sync.sync(syncOptions) + + return application.itemManager.findAnyItem(payload.uuid) +} + +export async function changePayloadTimeStamp(application, payload, timestamp, contentOverride) { + payload = application.payloadManager.collection.find(payload.uuid) + const changedPayload = new DecryptedPayload({ + ...payload, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + content: { + ...payload.content, + ...contentOverride, + }, + updated_at_timestamp: timestamp, + }) + + await application.itemManager.emitItemFromPayload(changedPayload) + + return application.itemManager.findAnyItem(payload.uuid) +} + +export async function changePayloadUpdatedAt(application, payload, updatedAt) { + const latestPayload = application.payloadManager.collection.find(payload.uuid) + const changedPayload = new DecryptedPayload({ + ...latestPayload, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + updated_at: updatedAt, + }) + + await application.itemManager.emitItemFromPayload(changedPayload) + + return application.itemManager.findAnyItem(payload.uuid) +} + +export async function changePayloadTimeStampDeleteAndSync(application, payload, timestamp, syncOptions) { + payload = application.payloadManager.collection.find(payload.uuid) + const changedPayload = new DeletedPayload({ + ...payload, + content: undefined, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + deleted: true, + updated_at_timestamp: timestamp, + }) + + await application.itemManager.emitItemFromPayload(changedPayload) + await application.sync.sync(syncOptions) +} diff --git a/packages/snjs/mocha/lib/fake_web_crypto.js b/packages/snjs/mocha/lib/fake_web_crypto.js new file mode 100644 index 000000000..046b19b7e --- /dev/null +++ b/packages/snjs/mocha/lib/fake_web_crypto.js @@ -0,0 +1,138 @@ +export default class FakeWebCrypto { + constructor() {} + + deinit() {} + + initialize() { + return + } + + randomString(len) { + const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + let randomString = '' + for (let i = 0; i < len; i++) { + const randomPoz = Math.floor(Math.random() * charSet.length) + randomString += charSet.substring(randomPoz, randomPoz + 1) + } + return randomString + } + + generateUUIDSync = () => { + const globalScope = getGlobalScope() + const crypto = globalScope.crypto || globalScope.msCrypto + if (crypto) { + const buf = new Uint32Array(4) + crypto.getRandomValues(buf) + let idx = -1 + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + idx++ + const r = (buf[idx >> 3] >> ((idx % 8) * 4)) & 15 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } else { + let d = new Date().getTime() + if (globalScope.performance && typeof globalScope.performance.now === 'function') { + d += performance.now() // use high-precision timer if available + } + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + const r = (d + Math.random() * 16) % 16 | 0 + d = Math.floor(d / 16) + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16) + }) + return uuid + } + } + + generateUUID = () => { + return this.generateUUIDSync() + } + + timingSafeEqual(a, b) { + return a === b + } + + base64Encode(text) { + return btoa(text) + } + + base64URLEncode(text) { + return this.base64Encode(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '') + } + + base64Decode(base64String) { + return atob(base64String) + } + + async pbkdf2(password, salt, iterations, length) { + return btoa(password + salt + iterations) + } + + generateRandomKey(bits) { + const length = bits / 8 + return this.randomString(length) + } + + async aes256CbcEncrypt(plaintext, iv, key) { + const data = { + plaintext, + iv, + key, + } + return btoa(JSON.stringify(data)) + } + + async aes256CbcDecrypt(ciphertext, iv, key) { + const data = JSON.parse(atob(ciphertext)) + if (data.key !== key || data.iv !== iv) { + return undefined + } + return data.plaintext + } + + async hmac256(message, key) { + return btoa(message + key) + } + + async sha256(text) { + return new SNWebCrypto().sha256(text) + } + + async hmac1(message, key) { + return btoa(message + key) + } + + async unsafeSha1(text) { + return btoa(text) + } + + argon2(password, salt, iterations, bytes, length) { + return btoa(password) + } + + xchacha20Encrypt(plaintext, nonce, key, assocData) { + const data = { + plaintext, + nonce, + key, + assocData, + } + return btoa(JSON.stringify(data)) + } + + xchacha20Decrypt(ciphertext, nonce, key, assocData) { + const data = JSON.parse(atob(ciphertext)) + if (data.key !== key || data.nonce !== nonce || data.assocData !== assocData) { + return undefined + } + return data.plaintext + } + + generateOtpSecret() { + return 'WQVV2GFBRQWU3UQZWQFZC37PSNRXKTA6' + } + + totpToken(secret, timestamp, tokenLength, step) { + return '123456' + } +} diff --git a/packages/snjs/mocha/lib/web_device_interface.js b/packages/snjs/mocha/lib/web_device_interface.js new file mode 100644 index 000000000..9b7e93c2b --- /dev/null +++ b/packages/snjs/mocha/lib/web_device_interface.js @@ -0,0 +1,157 @@ +/* eslint-disable no-undef */ + +const KEYCHAIN_STORAGE_KEY = 'keychain' + +export default class WebDeviceInterface { + environment = Environment.Web + + async getRawStorageValue(key) { + return localStorage.getItem(key) + } + + async getJsonParsedRawStorageValue(key) { + const value = await this.getRawStorageValue(key) + if (isNullOrUndefined(value)) { + return undefined + } + try { + return JSON.parse(value) + } catch (e) { + return value + } + } + + async getAllRawStorageKeyValues() { + const results = [] + for (const key of Object.keys(localStorage)) { + results.push({ + key: key, + value: localStorage[key], + }) + } + return results + } + + async setRawStorageValue(key, value) { + localStorage.setItem(key, value) + } + + async removeRawStorageValue(key) { + localStorage.removeItem(key) + } + + async removeAllRawStorageValues() { + localStorage.clear() + } + + async openDatabase(_identifier) { + return {} + } + + _getDatabaseKeyPrefix(identifier) { + if (identifier) { + return `${identifier}-item-` + } else { + return 'item-' + } + } + + _keyForPayloadId(id, identifier) { + return `${this._getDatabaseKeyPrefix(identifier)}${id}` + } + + async getAllRawDatabasePayloads(identifier) { + const models = [] + for (const key in localStorage) { + if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) { + models.push(JSON.parse(localStorage[key])) + } + } + return models + } + + async saveRawDatabasePayload(payload, identifier) { + localStorage.setItem(this._keyForPayloadId(payload.uuid, identifier), JSON.stringify(payload)) + } + + async saveRawDatabasePayloads(payloads, identifier) { + for (const payload of payloads) { + await this.saveRawDatabasePayload(payload, identifier) + } + } + + async removeRawDatabasePayloadWithId(id, identifier) { + localStorage.removeItem(this._keyForPayloadId(id, identifier)) + } + + async removeAllRawDatabasePayloads(identifier) { + for (const key in localStorage) { + if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) { + delete localStorage[key] + } + } + } + + /** @keychain */ + async getNamespacedKeychainValue(identifier) { + const keychain = await this.getRawKeychainValue(identifier) + if (!keychain) { + return + } + return keychain[identifier] + } + + async setNamespacedKeychainValue(value, identifier) { + let keychain = await this.getRawKeychainValue() + if (!keychain) { + keychain = {} + } + localStorage.setItem( + KEYCHAIN_STORAGE_KEY, + JSON.stringify({ + ...keychain, + [identifier]: value, + }), + ) + } + + async clearNamespacedKeychainValue(identifier) { + const keychain = await this.getRawKeychainValue() + if (!keychain) { + return + } + delete keychain[identifier] + localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(keychain)) + } + + /** Allows unit tests to set legacy keychain structure as it was <= 003 */ + // eslint-disable-next-line camelcase + async setLegacyRawKeychainValue(value) { + localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value)) + } + + async getRawKeychainValue() { + const keychain = localStorage.getItem(KEYCHAIN_STORAGE_KEY) + return JSON.parse(keychain) + } + + async clearRawKeychainValue() { + localStorage.removeItem(KEYCHAIN_STORAGE_KEY) + } + + performSoftReset() { + + } + + performHardReset() { + + } + + isDeviceDestroyed() { + return false + } + + deinit() { + + } +} diff --git a/packages/snjs/mocha/memory.test.js b/packages/snjs/mocha/memory.test.js new file mode 100644 index 000000000..806b9c10c --- /dev/null +++ b/packages/snjs/mocha/memory.test.js @@ -0,0 +1,32 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +/** + * Simple empty test page to create and deinit empty application + * Then check browser Memory tool to make sure there are no leaks. + */ +describe('memory', function () { + before(async function () { + localStorage.clear() + }) + + after(async function () { + localStorage.clear() + }) + + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + this.application = null + }) + + it('passes', async function () { + expect(true).to.equal(true) + }) +}) diff --git a/packages/snjs/mocha/mfa_service.test.js b/packages/snjs/mocha/mfa_service.test.js new file mode 100644 index 000000000..76c809212 --- /dev/null +++ b/packages/snjs/mocha/mfa_service.test.js @@ -0,0 +1,68 @@ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +const createApp = async () => Factory.createInitAppWithFakeCrypto(Environment.Web, Platform.MacWeb) + +const accountPassword = 'password' + +const registerApp = async (snApp) => { + const email = UuidGenerator.GenerateUuid() + const password = accountPassword + const ephemeral = false + const mergeLocal = true + + await snApp.register(email, password, ephemeral, mergeLocal) + return snApp +} + +describe('mfa service', () => { + it('generates 160 bit base32-encoded mfa secret', async () => { + const RFC4648 = /[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]/g + + const snApp = await createApp() + const secret = await snApp.generateMfaSecret() + expect(secret).to.have.lengthOf(32) + expect(secret.replace(RFC4648, '')).to.have.lengthOf(0) + + Factory.safeDeinit(snApp) + }) + + it('activates mfa, checks if enabled, deactivates mfa', async () => { + const snApp = await createApp().then(registerApp) + Factory.handlePasswordChallenges(snApp, accountPassword) + + expect(await snApp.isMfaActivated()).to.equal(false) + + const secret = await snApp.generateMfaSecret() + const token = await snApp.getOtpToken(secret) + + await snApp.enableMfa(secret, token) + + expect(await snApp.isMfaActivated()).to.equal(true) + + await snApp.disableMfa() + + expect(await snApp.isMfaActivated()).to.equal(false) + + Factory.safeDeinit(snApp) + }).timeout(Factory.TenSecondTimeout) + + it('prompts for account password when disabling mfa', async () => { + const snApp = await createApp().then(registerApp) + Factory.handlePasswordChallenges(snApp, accountPassword) + const secret = await snApp.generateMfaSecret() + const token = await snApp.getOtpToken(secret) + + sinon.spy(snApp.challengeService, 'sendChallenge') + await snApp.enableMfa(secret, token) + await snApp.disableMfa() + + const spyCall = snApp.challengeService.sendChallenge.getCall(0) + const challenge = spyCall.firstArg + expect(challenge.prompts).to.have.lengthOf(2) + expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword) + + Factory.safeDeinit(snApp) + }).timeout(Factory.TenSecondTimeout) +}) diff --git a/packages/snjs/mocha/migrations/2020-01-15-mobile.test.js b/packages/snjs/mocha/migrations/2020-01-15-mobile.test.js new file mode 100644 index 000000000..be114b69c --- /dev/null +++ b/packages/snjs/mocha/migrations/2020-01-15-mobile.test.js @@ -0,0 +1,1042 @@ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import * as Utils from '../lib/Utils.js' +import FakeWebCrypto from '../lib/fake_web_crypto.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('2020-01-15 mobile migration', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + it( + '2020-01-15 migration with passcode and account', + async function () { + let application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'pc_params', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + const passcodeTiming = 'immediately' + + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + const customServer = 'http://server-dev.standardnotes.org' + await application.deviceInterface.setRawStorageValue( + 'user', + JSON.stringify({ email: identifier, server: customServer }), + ) + await application.deviceInterface.setLegacyRawKeychainValue({ + offline: { + pw: passcodeKey.serverPassword, + timing: passcodeTiming, + }, + }) + /** Wrap account key with passcode key and store in storage */ + const keyPayload = new DecryptedPayload({ + uuid: Utils.generateUuid(), + content_type: 'SN|Mobile|EncryptedKeys', + content: { + accountKeys: { + jwt: 'foo', + mk: accountKey.masterKey, + ak: accountKey.dataAuthenticationKey, + pw: accountKey.serverPassword, + }, + }, + }) + const encryptedKeyParams = await operator003.generateEncryptedParametersAsync(keyPayload, passcodeKey) + const wrappedKey = new EncryptedPayload({ ...keyPayload.ejected(), ...encryptedKeyParams }) + await application.deviceInterface.setRawStorageValue('encrypted_account_keys', JSON.stringify(wrappedKey)) + const biometricPrefs = { enabled: true, timing: 'immediately' } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + /** setup options */ + const lastExportDate = '2020:02' + await application.deviceInterface.setRawStorageValue('LastExportDateKey', lastExportDate) + const options = JSON.stringify({ + sortBy: 'userModifiedAt', + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: true, + hideDates: false, + hideTags: false, + }) + await application.deviceInterface.setRawStorageValue('options', options) + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if ( + prompt.validation === ChallengeValidation.None || + prompt.validation === ChallengeValidation.LocalPasscode + ) { + values.push(CreateChallengeValue(prompt, passcode)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + const values = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, values) + } + await application.prepareForLaunch({ + receiveChallenge, + }) + await application.launch(true) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(typeof keyParams).to.equal('object') + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + + const keychainValue = await application.deviceInterface.getNamespacedKeychainValue(application.identifier) + expect(keychainValue).to.not.be.ok + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + expect( + await application.diskStorageService.getValue(NonwrappedStorageKey.MobileFirstRun, StorageValueModes.Nonwrapped), + ).to.equal(false) + + expect( + await application.diskStorageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.enabled) + expect( + await application.diskStorageService.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.timing) + expect(await application.getUser().email).to.equal(identifier) + + const appId = application.identifier + console.warn('Expecting exception due to deiniting application while trying to renew session') + + /** Full sync completed event will not trigger due to mocked credentials, + * thus we manually need to mark any sync dependent migrations as complete. */ + await application.migrationService.markMigrationsAsDone() + await Factory.safeDeinit(application) + + /** Recreate application and ensure storage values are consistent */ + application = Factory.createApplicationWithFakeCrypto(appId) + await application.prepareForLaunch({ + receiveChallenge, + }) + await application.launch(true) + expect(await application.getUser().email).to.equal(identifier) + expect(await application.getHost()).to.equal(customServer) + const preferences = await application.diskStorageService.getValue('preferences') + expect(preferences.sortBy).to.equal('userModifiedAt') + expect(preferences.sortReverse).to.be.false + expect(preferences.hideDate).to.be.false + expect(preferences.hideTags).to.be.false + expect(preferences.hideNotePreview).to.be.true + expect(preferences.lastExportDate).to.equal(lastExportDate) + expect(preferences.doNotShowAgainUnsupportedEditors).to.be.false + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }, + Factory.TwentySecondTimeout, + ) + + it('2020-01-15 migration with passcode only', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'pc_params', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + const passcodeTiming = 'immediately' + await application.deviceInterface.setLegacyRawKeychainValue({ + offline: { + pw: passcodeKey.serverPassword, + timing: passcodeTiming, + }, + }) + + const biometricPrefs = { enabled: true, timing: 'immediately' } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + const passcodeKeyboardType = 'numeric' + await application.deviceInterface.setRawStorageValue('passcodeKeyboardType', passcodeKeyboardType) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, passcodeKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + /** setup options */ + await application.deviceInterface.setRawStorageValue('DoNotShowAgainUnsupportedEditorsKey', true) + const options = JSON.stringify({ + sortBy: undefined, + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: false, + hideDates: undefined, + hideTags: true, + }) + await application.deviceInterface.setRawStorageValue('options', options) + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None || prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, passcode)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + await Factory.sleep(0) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await application.launch(true) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(passcodeKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(passcodeKey.dataAuthenticationKey) + /** Root key is in memory with passcode only, so server password can be defined */ + expect(rootKey.serverPassword).to.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + + const keychainValue = await application.deviceInterface.getNamespacedKeychainValue(application.identifier) + expect(keychainValue).to.not.be.ok + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + expect( + await application.diskStorageService.getValue(NonwrappedStorageKey.MobileFirstRun, StorageValueModes.Nonwrapped), + ).to.equal(false) + expect( + await application.diskStorageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.enabled) + expect( + await application.diskStorageService.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.timing) + expect( + await application.diskStorageService.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped), + ).to.eql(passcodeTiming) + expect( + await application.diskStorageService.getValue(StorageKey.MobilePasscodeKeyboardType, StorageValueModes.Nonwrapped), + ).to.eql(passcodeKeyboardType) + const preferences = await application.diskStorageService.getValue('preferences') + expect(preferences.sortBy).to.equal(undefined) + expect(preferences.sortReverse).to.be.false + expect(preferences.hideNotePreview).to.be.false + expect(preferences.hideDate).to.be.false + expect(preferences.hideTags).to.be.true + expect(preferences.lastExportDate).to.equal(undefined) + expect(preferences.doNotShowAgainUnsupportedEditors).to.be.true + await Factory.safeDeinit(application) + }) + + it('2020-01-15 migration with passcode-only missing keychain', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'pc_params', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + const biometricPrefs = { enabled: true, timing: 'immediately' } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + const passcodeKeyboardType = 'numeric' + await application.deviceInterface.setRawStorageValue('passcodeKeyboardType', passcodeKeyboardType) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, passcodeKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + /** setup options */ + await application.deviceInterface.setRawStorageValue('DoNotShowAgainUnsupportedEditorsKey', true) + const options = JSON.stringify({ + sortBy: undefined, + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: false, + hideDates: undefined, + hideTags: true, + }) + await application.deviceInterface.setRawStorageValue('options', options) + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None || prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, passcode)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + await Factory.sleep(0) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + await application.launch(true) + + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.errorDecrypting).to.not.be.ok + + /** application should not crash */ + await Factory.safeDeinit(application) + }) + + it('2020-01-15 migration with account only', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: identifier })) + expect(accountKey.keyVersion).to.equal(ProtocolVersion.V003) + await application.deviceInterface.setLegacyRawKeychainValue({ + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + ak: accountKey.dataAuthenticationKey, + jwt: 'foo', + version: ProtocolVersion.V003, + }) + const biometricPrefs = { + enabled: true, + timing: 'immediately', + } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + /** setup options */ + const lastExportDate = '2020:02' + await application.deviceInterface.setRawStorageValue('LastExportDateKey', lastExportDate) + await application.deviceInterface.setRawStorageValue('DoNotShowAgainUnsupportedEditorsKey', false) + const options = JSON.stringify({ + sortBy: 'created_at', + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: true, + hideDates: false, + }) + await application.deviceInterface.setRawStorageValue('options', options) + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None) { + values.push(CreateChallengeValue(prompt, password)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + /** Runs migration */ + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + + const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped) + expect(typeof keyParams).to.equal('object') + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + expect( + await application.diskStorageService.getValue(NonwrappedStorageKey.MobileFirstRun, StorageValueModes.Nonwrapped), + ).to.equal(false) + expect( + await application.diskStorageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.enabled) + expect( + await application.diskStorageService.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.timing) + expect(await application.getUser().email).to.equal(identifier) + const preferences = await application.diskStorageService.getValue('preferences') + expect(preferences.sortBy).to.equal('created_at') + expect(preferences.sortReverse).to.be.false + expect(preferences.hideDate).to.be.false + expect(preferences.hideNotePreview).to.be.true + expect(preferences.lastExportDate).to.equal(lastExportDate) + expect(preferences.doNotShowAgainUnsupportedEditors).to.be.false + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 launching with account but missing keychain', async function () { + /** + * We expect that the keychain will attempt to be recovered + * We expect two challenges, one to recover just the keychain + * and another to recover the user session via a sign in request + */ + + /** Register a real user so we can attempt to sign back into this account later */ + const tempApp = await Factory.createInitAppWithFakeCrypto(Environment.Mobile, Platform.Ios) + const email = UuidGenerator.GenerateUuid() + const password = UuidGenerator.GenerateUuid() + /** Register with 003 account */ + await Factory.registerOldUser({ + application: tempApp, + email: email, + password: password, + version: ProtocolVersion.V003, + }) + const accountKey = tempApp.protocolService.getRootKey() + await Factory.safeDeinit(tempApp) + localStorage.clear() + + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + /** Create old version account parameters */ + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: email })) + expect(accountKey.keyVersion).to.equal(ProtocolVersion.V003) + + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.placeholder === SessionStrings.EmailInputPlaceholder) { + values.push(CreateChallengeValue(prompt, email)) + } else if (prompt.placeholder === SessionStrings.PasswordInputPlaceholder) { + values.push(CreateChallengeValue(prompt, password)) + } else { + throw Error('Unhandled prompt') + } + } + return values + } + let totalChallenges = 0 + const expectedChallenges = 2 + const receiveChallenge = async (challenge) => { + totalChallenges++ + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + + /** Recovery migration is non-blocking, so let's block for it */ + await Factory.sleep(1.0) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.be.ok + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + expect(await application.getUser().email).to.equal(email) + expect(await application.apiService.getSession()).to.be.ok + expect(totalChallenges).to.equal(expectedChallenges) + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 migration with 002 account should not create 003 data', async function () { + /** There was an issue where 002 account loading new app would create new default items key + * with 003 version. Should be 002. */ + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator002 = new SNProtocolOperator002(new FakeWebCrypto()) + const identifier = 'foo' + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator002.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: identifier })) + expect(accountKey.keyVersion).to.equal(ProtocolVersion.V002) + await application.deviceInterface.setLegacyRawKeychainValue({ + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + ak: accountKey.dataAuthenticationKey, + jwt: 'foo', + }) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator002.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None) { + values.push(CreateChallengeValue(prompt, password)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + + const itemsKey = application.itemManager.getDisplayableItemsKeys()[0] + expect(itemsKey.keyVersion).to.equal(ProtocolVersion.V002) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + expect(await application.getUser().email).to.equal(identifier) + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 migration with 001 account detect 001 version even with missing info', async function () { + /** If 001 account, and for some reason we dont have version stored, the migrations + * should determine correct version based on saved payloads */ + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator001 = new SNProtocolOperator001(new FakeWebCrypto()) + const identifier = 'foo' + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator001.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify({ + ...accountKey.keyParams.getPortableValue(), + version: undefined, + }), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: identifier })) + expect(accountKey.keyVersion).to.equal(ProtocolVersion.V001) + await application.deviceInterface.setLegacyRawKeychainValue({ + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + jwt: 'foo', + }) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator001.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None) { + values.push(CreateChallengeValue(prompt, password)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + + const itemsKey = application.itemManager.getDisplayableItemsKeys()[0] + expect(itemsKey.keyVersion).to.equal(ProtocolVersion.V001) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + expect(await application.getUser().email).to.equal(identifier) + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 successfully creates session if jwt is stored in keychain', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: identifier })) + + await application.deviceInterface.setLegacyRawKeychainValue({ + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + ak: accountKey.dataAuthenticationKey, + jwt: 'foo', + version: ProtocolVersion.V003, + }) + + await application.prepareForLaunch({ receiveChallenge: () => {} }) + await application.launch(true) + + expect(application.apiService.getSession()).to.be.ok + + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 successfully creates session if jwt is stored in storage', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + await application.deviceInterface.setRawStorageValue('user', JSON.stringify({ email: identifier, jwt: 'foo' })) + await application.deviceInterface.setLegacyRawKeychainValue({ + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + ak: accountKey.dataAuthenticationKey, + version: ProtocolVersion.V003, + }) + + await application.prepareForLaunch({ receiveChallenge: () => {} }) + await application.launch(true) + + expect(application.apiService.getSession()).to.be.ok + + await Factory.safeDeinit(application) + }).timeout(10000) + + it('2020-01-15 migration with no account and no passcode', async function () { + const application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const biometricPrefs = { + enabled: true, + timing: 'immediately', + } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + await application.deviceInterface.saveRawDatabasePayload(notePayload.ejected(), application.identifier) + /** setup options */ + await application.deviceInterface.setRawStorageValue('DoNotShowAgainUnsupportedEditorsKey', true) + const options = JSON.stringify({ + sortBy: 'created_at', + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: true, + hideDates: false, + }) + await application.deviceInterface.setRawStorageValue('options', options) + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.None || prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, passcode)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.not.be.ok + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + expect( + await application.diskStorageService.getValue(NonwrappedStorageKey.MobileFirstRun, StorageValueModes.Nonwrapped), + ).to.equal(false) + expect( + await application.diskStorageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.enabled) + expect( + await application.diskStorageService.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.timing) + const preferences = await application.diskStorageService.getValue('preferences') + expect(preferences.sortBy).to.equal('created_at') + expect(preferences.sortReverse).to.be.false + expect(preferences.hideDate).to.be.false + expect(preferences.hideNotePreview).to.be.true + expect(preferences.lastExportDate).to.equal(undefined) + expect(preferences.doNotShowAgainUnsupportedEditors).to.be.true + await Factory.safeDeinit(application) + }) + + it( + '2020-01-15 migration from mobile version 3.0.16', + async function () { + /** + * In version 3.0.16, encrypted account keys were stored in keychain, not storage. + * This was migrated in version 3.0.17, but we want to be sure we can go from 3.0.16 + * to current state directly. + */ + let application = await Factory.createAppWithRandNamespace(Environment.Mobile, Platform.Ios) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'pc_params', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + const passcodeTiming = 'immediately' + + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + await application.deviceInterface.setRawStorageValue( + 'auth_params', + JSON.stringify(accountKey.keyParams.getPortableValue()), + ) + const customServer = 'http://server-dev.standardnotes.org' + await application.deviceInterface.setRawStorageValue( + 'user', + JSON.stringify({ email: identifier, server: customServer }), + ) + /** Wrap account key with passcode key and store in storage */ + const keyPayload = new DecryptedPayload({ + uuid: Utils.generateUuid(), + content_type: 'SN|Mobile|EncryptedKeys', + content: { + accountKeys: { + jwt: 'foo', + mk: accountKey.masterKey, + ak: accountKey.dataAuthenticationKey, + pw: accountKey.serverPassword, + }, + }, + }) + const encryptedKeyParams = await operator003.generateEncryptedParametersAsync(keyPayload, passcodeKey) + const wrappedKey = new EncryptedPayload({ ...keyPayload, ...encryptedKeyParams }) + await application.deviceInterface.setLegacyRawKeychainValue({ + encryptedAccountKeys: wrappedKey, + offline: { + pw: passcodeKey.serverPassword, + timing: passcodeTiming, + }, + }) + const biometricPrefs = { enabled: true, timing: 'immediately' } + /** Create legacy storage. Storage in mobile was never wrapped. */ + await application.deviceInterface.setRawStorageValue('biometrics_prefs', JSON.stringify(biometricPrefs)) + await application.deviceInterface.setRawStorageValue(NonwrappedStorageKey.MobileFirstRun, false) + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + /** setup options */ + const lastExportDate = '2020:02' + await application.deviceInterface.setRawStorageValue('LastExportDateKey', lastExportDate) + const options = JSON.stringify({ + sortBy: 'userModifiedAt', + sortReverse: undefined, + selectedTagIds: [], + hidePreviews: true, + hideDates: false, + hideTags: false, + }) + await application.deviceInterface.setRawStorageValue('options', options) + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if ( + prompt.validation === ChallengeValidation.None || + prompt.validation === ChallengeValidation.LocalPasscode + ) { + values.push(CreateChallengeValue(prompt, passcode)) + } + if (prompt.validation === ChallengeValidation.Biometric) { + values.push(CreateChallengeValue(prompt, true)) + } + } + return values + } + const receiveChallenge = async (challenge) => { + const values = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, values) + } + await application.prepareForLaunch({ + receiveChallenge, + }) + await application.launch(true) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(typeof keyParams).to.equal('object') + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + + const keychainValue = await application.deviceInterface.getNamespacedKeychainValue(application.identifier) + expect(keychainValue).to.not.be.ok + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + expect( + await application.diskStorageService.getValue(NonwrappedStorageKey.MobileFirstRun, StorageValueModes.Nonwrapped), + ).to.equal(false) + + expect( + await application.diskStorageService.getValue(StorageKey.BiometricsState, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.enabled) + expect( + await application.diskStorageService.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped), + ).to.equal(biometricPrefs.timing) + expect(await application.getUser().email).to.equal(identifier) + + const appId = application.identifier + console.warn('Expecting exception due to deiniting application while trying to renew session') + /** Full sync completed event will not trigger due to mocked credentials, + * thus we manually need to mark any sync dependent migrations as complete. */ + await application.migrationService.markMigrationsAsDone() + await Factory.safeDeinit(application) + + /** Recreate application and ensure storage values are consistent */ + application = Factory.createApplicationWithFakeCrypto(appId) + await application.prepareForLaunch({ + receiveChallenge, + }) + await application.launch(true) + expect(await application.getUser().email).to.equal(identifier) + expect(await application.getHost()).to.equal(customServer) + const preferences = await application.diskStorageService.getValue('preferences') + expect(preferences.sortBy).to.equal('userModifiedAt') + expect(preferences.sortReverse).to.be.false + expect(preferences.hideDate).to.be.false + expect(preferences.hideTags).to.be.false + expect(preferences.hideNotePreview).to.be.true + expect(preferences.lastExportDate).to.equal(lastExportDate) + expect(preferences.doNotShowAgainUnsupportedEditors).to.be.false + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }, + Factory.TwentySecondTimeout, + ) +}) diff --git a/packages/snjs/mocha/migrations/2020-01-15-web.test.js b/packages/snjs/mocha/migrations/2020-01-15-web.test.js new file mode 100644 index 000000000..5e3c131ac --- /dev/null +++ b/packages/snjs/mocha/migrations/2020-01-15-web.test.js @@ -0,0 +1,584 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import FakeWebCrypto from '../lib/fake_web_crypto.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('2020-01-15 web migration', () => { + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + /** + * This test will pass but sync afterwards will not be successful + * as we are using a random value for the legacy session token + */ + it('2020-01-15 migration with passcode and account', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'offlineParams', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + + /** Create arbitrary storage values and make sure they're migrated */ + const arbitraryValues = { + foo: 'bar', + zar: 'tar', + har: 'car', + } + for (const key of Object.keys(arbitraryValues)) { + await application.deviceInterface.setRawStorageValue(key, arbitraryValues[key]) + } + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + + /** Create legacy storage and encrypt it with passcode */ + const embeddedStorage = { + mk: accountKey.masterKey, + ak: accountKey.dataAuthenticationKey, + pw: accountKey.serverPassword, + jwt: 'anything', + /** Legacy versions would store json strings inside of embedded storage */ + auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()), + } + const storagePayload = new DecryptedPayload({ + uuid: await operator003.crypto.generateUUID(), + content_type: ContentType.EncryptedStorage, + content: { + storage: embeddedStorage, + }, + }) + const encryptionParams = await operator003.generateEncryptedParametersAsync(storagePayload, passcodeKey) + const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams }) + await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload)) + + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + await application.prepareForLaunch({ + receiveChallenge: async (challenge) => { + application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)]) + }, + }) + + await application.launch(true) + expect(application.sessionManager.online()).to.equal(true) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + expect(await application.deviceInterface.getRawStorageValue('offlineParams')).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped) + expect(typeof keyParams).to.equal('object') + + /** Embedded value should match */ + const migratedKeyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(migratedKeyParams).to.eql(JSON.parse(embeddedStorage.auth_params)) + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + /** Application should not retain server password from legacy versions */ + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + /** Ensure arbitrary values have been migrated */ + for (const key of Object.keys(arbitraryValues)) { + const value = await application.diskStorageService.getValue(key) + expect(arbitraryValues[key]).to.equal(value) + } + + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }).timeout(15000) + + it('2020-01-15 migration with passcode only', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator003.createRootKey(identifier, passcode) + await application.deviceInterface.setRawStorageValue( + 'offlineParams', + JSON.stringify(passcodeKey.keyParams.getPortableValue()), + ) + + /** Create arbitrary storage values and make sure they're migrated */ + const arbitraryValues = { + foo: 'bar', + zar: 'tar', + har: 'car', + } + for (const key of Object.keys(arbitraryValues)) { + await application.deviceInterface.setRawStorageValue(key, arbitraryValues[key]) + } + + const embeddedStorage = { + ...arbitraryValues, + } + const storagePayload = new DecryptedPayload({ + uuid: await operator003.crypto.generateUUID(), + content: { + storage: embeddedStorage, + }, + content_type: ContentType.EncryptedStorage, + }) + const encryptionParams = await operator003.generateEncryptedParametersAsync(storagePayload, passcodeKey) + const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams }) + await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload)) + + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, passcodeKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + await application.prepareForLaunch({ + receiveChallenge: async (challenge) => { + application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)]) + }, + }) + await application.launch(true) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + + expect(await application.deviceInterface.getRawStorageValue('offlineParams')).to.not.be.ok + + /** Embedded value should match */ + const migratedKeyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(migratedKeyParams).to.eql(embeddedStorage.auth_params) + const rootKey = await application.protocolService.getRootKey() + expect(rootKey.masterKey).to.equal(passcodeKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(passcodeKey.dataAuthenticationKey) + /** Root key is in memory with passcode only, so server password can be defined */ + expect(rootKey.serverPassword).to.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + /** Ensure arbitrary values have been migrated */ + for (const key of Object.keys(arbitraryValues)) { + const value = await application.diskStorageService.getValue(key) + expect(arbitraryValues[key]).to.equal(value) + } + await Factory.safeDeinit(application) + }) + + /** + * This test will pass but sync afterwards will not be successful + * as we are using a random value for the legacy session token + */ + it('2020-01-15 migration with account only', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator003 = new SNProtocolOperator003(new FakeWebCrypto()) + const identifier = 'foo' + + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator003.createRootKey(identifier, password) + + /** Create arbitrary storage values and make sure they're migrated */ + const storage = { + foo: 'bar', + zar: 'tar', + har: 'car', + mk: accountKey.masterKey, + ak: accountKey.dataAuthenticationKey, + pw: accountKey.serverPassword, + jwt: 'anything', + /** Legacy versions would store json strings inside of embedded storage */ + auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()), + } + for (const key of Object.keys(storage)) { + await application.deviceInterface.setRawStorageValue(key, storage[key]) + } + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, passcode)) + } else { + /** We will be prompted to reauthetnicate our session, not relevant to this test + * but pass any value to avoid exception + */ + values.push(CreateChallengeValue(prompt, 'foo')) + } + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + expect(application.sessionManager.online()).to.equal(true) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + /** Embedded value should match */ + const migratedKeyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue()) + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.be.ok + + expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped) + expect(typeof keyParams).to.equal('object') + + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + /** Ensure arbitrary values have been migrated */ + for (const key of Object.keys(storage)) { + /** Is stringified in storage, but parsed in storageService */ + if (key === 'auth_params') { + continue + } + const value = await application.diskStorageService.getValue(key) + expect(storage[key]).to.equal(value) + } + + console.warn('Expecting exception due to deiniting application while trying to renew session') + await Factory.safeDeinit(application) + }) + + it('2020-01-15 migration with no account and no passcode', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + /** Create arbitrary storage values and make sure they're migrated */ + const storage = { + foo: 'bar', + zar: 'tar', + har: 'car', + } + for (const key of Object.keys(storage)) { + await application.deviceInterface.setRawStorageValue(key, storage[key]) + } + + /** Create item and store it in db */ + const notePayload = Factory.createNotePayload() + await application.deviceInterface.saveRawDatabasePayload(notePayload.ejected(), application.identifier) + + /** Run migration */ + await application.prepareForLaunch({ + receiveChallenge: (_challenge) => { + return null + }, + }) + await application.launch(true) + + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone) + + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.not.be.ok + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone) + + expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + /** Ensure arbitrary values have been migrated */ + for (const key of Object.keys(storage)) { + const value = await application.diskStorageService.getValue(key) + expect(storage[key]).to.equal(value) + } + + await Factory.safeDeinit(application) + }) + + /** + * This test will pass but sync afterwards will not be successful + * as we are using a random value for the legacy session token + */ + it('2020-01-15 migration from app v1.0.1 with account only', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator001 = new SNProtocolOperator001(new FakeWebCrypto()) + const identifier = 'foo' + + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator001.createRootKey(identifier, password) + + /** Create arbitrary storage values and make sure they're migrated */ + const storage = { + mk: accountKey.masterKey, + pw: accountKey.serverPassword, + jwt: 'anything', + /** Legacy versions would store json strings inside of embedded storage */ + auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()), + user: JSON.stringify({ uuid: 'anything', email: 'anything' }), + } + for (const key of Object.keys(storage)) { + await application.deviceInterface.setRawStorageValue(key, storage[key]) + } + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator001.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Run migration */ + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + /** We will be prompted to reauthetnicate our session, not relevant to this test + * but pass any value to avoid exception + */ + values.push(CreateChallengeValue(prompt, 'foo')) + } + return values + } + const receiveChallenge = async (challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + await application.prepareForLaunch({ + receiveChallenge: receiveChallenge, + }) + await application.launch(true) + expect(application.sessionManager.online()).to.equal(true) + expect(application.sessionManager.getUser()).to.be.ok + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + /** Embedded value should match */ + const migratedKeyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue()) + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.be.ok + + expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('ak')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('mk')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('pw')).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped) + expect(typeof keyParams).to.equal('object') + + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V001) + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + /** Ensure arbitrary values have been migrated */ + for (const key of Object.keys(storage)) { + /** Is stringified in storage, but parsed in storageService */ + const value = await application.diskStorageService.getValue(key) + if (key === 'auth_params') { + continue + } else if (key === 'user') { + expect(storage[key]).to.equal(JSON.stringify(value)) + } else { + expect(storage[key]).to.equal(value) + } + } + await Factory.safeDeinit(application) + }) + + it('2020-01-15 migration from 002 app with account and passcode but missing offlineParams.version', async function () { + /** + * There was an issue where if the user had offlineParams but it was missing the version key, + * the user could not get past the passcode migration screen. + */ + const application = await Factory.createAppWithRandNamespace() + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + const operator002 = new SNProtocolOperator002(new FakeWebCrypto()) + const identifier = 'foo' + const passcode = 'bar' + /** Create old version passcode parameters */ + const passcodeKey = await operator002.createRootKey(identifier, passcode) + + /** The primary chaos agent */ + const offlineParams = passcodeKey.keyParams.getPortableValue() + omitInPlace(offlineParams, ['version']) + + await application.deviceInterface.setRawStorageValue('offlineParams', JSON.stringify(offlineParams)) + + /** Create old version account parameters */ + const password = 'tar' + const accountKey = await operator002.createRootKey(identifier, password) + + /** Create legacy storage and encrypt it with passcode */ + const embeddedStorage = { + mk: accountKey.masterKey, + ak: accountKey.dataAuthenticationKey, + pw: accountKey.serverPassword, + jwt: 'anything', + /** Legacy versions would store json strings inside of embedded storage */ + auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()), + user: JSON.stringify({ uuid: 'anything', email: 'anything' }), + } + const storagePayload = new DecryptedPayload({ + uuid: await operator002.crypto.generateUUID(), + content_type: ContentType.EncryptedStorage, + content: { + storage: embeddedStorage, + }, + }) + const encryptionParams = await operator002.generateEncryptedParametersAsync(storagePayload, passcodeKey) + const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams }) + await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload)) + + /** Create encrypted item and store it in db */ + const notePayload = Factory.createNotePayload() + const noteEncryptionParams = await operator002.generateEncryptedParametersAsync(notePayload, accountKey) + const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams }) + await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier) + + /** Runs migration */ + await application.prepareForLaunch({ + receiveChallenge: async (challenge) => { + application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)]) + }, + }) + await application.launch(true) + expect(application.sessionManager.online()).to.equal(true) + expect(application.sessionManager.getUser()).to.be.ok + expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper) + /** Should be decrypted */ + const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default) + const valueStore = application.diskStorageService.values[storageMode] + expect(valueStore.content_type).to.not.be.ok + /** Embedded value should match */ + const migratedKeyParams = await application.diskStorageService.getValue( + StorageKey.RootKeyParams, + StorageValueModes.Nonwrapped, + ) + expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue()) + const rootKey = await application.protocolService.getRootKey() + expect(rootKey).to.be.ok + + expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('ak')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('mk')).to.not.be.ok + expect(await application.deviceInterface.getRawStorageValue('pw')).to.not.be.ok + + const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped) + expect(typeof keyParams).to.equal('object') + + expect(rootKey.masterKey).to.equal(accountKey.masterKey) + expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey) + expect(rootKey.serverPassword).to.not.be.ok + expect(rootKey.keyVersion).to.equal(ProtocolVersion.V002) + + /** Expect note is decrypted */ + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + const retrievedNote = application.itemManager.getDisplayableNotes()[0] + expect(retrievedNote.uuid).to.equal(notePayload.uuid) + expect(retrievedNote.content.text).to.equal(notePayload.content.text) + + await Factory.safeDeinit(application) + }) +}) diff --git a/packages/snjs/mocha/migrations/migration.test.js b/packages/snjs/mocha/migrations/migration.test.js new file mode 100644 index 000000000..7bc47092f --- /dev/null +++ b/packages/snjs/mocha/migrations/migration.test.js @@ -0,0 +1,175 @@ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('migrations', () => { + const allMigrations = ['2.0.0', '2.0.15', '2.7.0', '2.20.0', '2.36.0', '2.42.0'] + + beforeEach(async () => { + localStorage.clear() + }) + + afterEach(async () => { + localStorage.clear() + }) + + it('version number is stored as string', async function () { + const application = await Factory.createInitAppWithFakeCrypto() + const version = await application.migrationService.getStoredSnjsVersion() + expect(typeof version).to.equal('string') + await Factory.safeDeinit(application) + }) + + it('should return correct required migrations if stored version is 1.0.0', async function () { + expect((await SNMigrationService.getRequiredMigrations('1.0.0')).length).to.equal(allMigrations.length) + }) + + it('should return correct required migrations if stored version is 2.0.0', async function () { + expect((await SNMigrationService.getRequiredMigrations('2.0.0')).length).to.equal(allMigrations.length - 1) + }) + + it('should return 0 required migrations if stored version is futuristic', async function () { + expect((await SNMigrationService.getRequiredMigrations('100.0.1')).length).to.equal(0) + }) + + it('after running base migration, legacy structure should set version as 1.0.0', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Set up 1.0.0 structure with tell-tale storage key */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + await application.migrationService.runBaseMigrationPreRun() + expect(await application.migrationService.getStoredSnjsVersion()).to.equal('1.0.0') + await Factory.safeDeinit(application) + }) + + it('after running base migration, 2.0.0 structure set version as 2.0.0', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Set up 2.0.0 structure with tell-tale storage key */ + await application.deviceInterface.setRawStorageValue( + namespacedKey(application.identifier, 'last_migration_timestamp'), + 'anything', + ) + await application.migrationService.runBaseMigrationPreRun() + expect(await application.migrationService.getStoredSnjsVersion()).to.equal('2.0.0') + await Factory.safeDeinit(application) + }) + + it('after running base migration with no present storage values, should set version to current', async function () { + const application = await Factory.createAppWithRandNamespace() + await application.migrationService.runBaseMigrationPreRun() + expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion) + await Factory.safeDeinit(application) + }) + + it('after running all migrations from a 1.0.0 installation, should set stored version to current', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Set up 1.0.0 structure with tell-tale storage key */ + await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything'])) + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion) + await Factory.safeDeinit(application) + }) + + it('after running all migrations from a 2.0.0 installation, should set stored version to current', async function () { + const application = await Factory.createAppWithRandNamespace() + /** Set up 2.0.0 structure with tell-tale storage key */ + await application.deviceInterface.setRawStorageValue('last_migration_timestamp', JSON.stringify(['anything'])) + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion) + await Factory.safeDeinit(application) + }) + + it('should be correct migration count coming from 1.0.0', async function () { + const application = await Factory.createAppWithRandNamespace() + await application.deviceInterface.setRawStorageValue('migrations', 'anything') + await application.migrationService.runBaseMigrationPreRun() + expect(await application.migrationService.getStoredSnjsVersion()).to.equal('1.0.0') + const pendingMigrations = await SNMigrationService.getRequiredMigrations( + await application.migrationService.getStoredSnjsVersion(), + ) + expect(pendingMigrations.length).to.equal(allMigrations.length) + expect(pendingMigrations[0].version()).to.equal('2.0.0') + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion) + await Factory.safeDeinit(application) + }) + + it('2.20.0 remove mfa migration', async function () { + const application = await Factory.createAppWithRandNamespace() + + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + + const mfaItem = CreateDecryptedItemFromPayload( + new DecryptedPayload({ + uuid: '123', + content_type: 'SF|MFA', + content: FillItemContent({ + key: '123', + }), + }), + ) + await application.mutator.insertItem(mfaItem) + await application.sync.sync() + + expect(application.items.getItems('SF|MFA').length).to.equal(1) + expect( + (await application.diskStorageService.getAllRawPayloads()).filter((p) => p.content_type === 'SF|MFA').length, + ).to.equal(1) + + /** Run migration */ + const migration = new Migration2_20_0(application.migrationService.services) + await migration.handleStage(ApplicationStage.LoadedDatabase_12) + + expect(application.items.getItems('SF|MFA').length).to.equal(0) + expect( + (await application.diskStorageService.getAllRawPayloads()).filter((p) => p.content_type === 'SF|MFA').length, + ).to.equal(0) + + await Factory.safeDeinit(application) + }) + + it('2.42.0 remove no distraction theme', async function () { + const application = await Factory.createAppWithRandNamespace() + + await application.prepareForLaunch({ + receiveChallenge: () => {}, + }) + await application.launch(true) + + const noDistractionItem = CreateDecryptedItemFromPayload( + new DecryptedPayload({ + uuid: '123', + content_type: ContentType.Theme, + content: FillItemContent({ + package_info: { + identifier: 'org.standardnotes.theme-no-distraction', + }, + }), + }), + ) + await application.mutator.insertItem(noDistractionItem) + await application.sync.sync() + + expect(application.items.getItems(ContentType.Theme).length).to.equal(1) + + /** Run migration */ + const migration = new Migration2_42_0(application.migrationService.services) + await migration.handleStage(ApplicationStage.FullSyncCompleted_13) + await application.sync.sync() + + expect(application.items.getItems(ContentType.Theme).length).to.equal(0) + + await Factory.safeDeinit(application) + }) +}) diff --git a/packages/snjs/mocha/migrations/tags-to-folders.test.js b/packages/snjs/mocha/migrations/tags-to-folders.test.js new file mode 100644 index 000000000..d617cc89e --- /dev/null +++ b/packages/snjs/mocha/migrations/tags-to-folders.test.js @@ -0,0 +1,191 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +const setupRandomUuid = () => { + let currentId = 0 + + UuidGenerator.SetGenerator(() => String(currentId++)) +} + +describe('web native folders migration', () => { + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + setupRandomUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + // TODO: cleanup uuid behind us or we'll mess other tests. + }) + + it('migration with flat tag folders', async function () { + const titles = ['a', 'b', 'c'] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + a: { _uuid: 'a' }, + b: { _uuid: 'b' }, + c: { _uuid: 'c' }, + }) + }) + + it('migration with simple tag folders', async function () { + const titles = ['a.b.c', 'b', 'a.b'] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + a: { + _uuid: '0', + b: { + _uuid: 'a.b', + c: { _uuid: 'a.b.c' }, + }, + }, + b: { _uuid: 'b' }, + }) + }) + + it('migration with more complex cases', async function () { + const titles = ['a.b.c', 'b', 'a.b'] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + a: { + _uuid: '0', + b: { + _uuid: 'a.b', + c: { _uuid: 'a.b.c' }, + }, + }, + b: { _uuid: 'b' }, + }) + }) + + it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async function () { + const titles = ['y.2', 'w.3', 'y'] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + w: { + _uuid: '0', + 3: { + _uuid: 'w.3', + }, + }, + y: { _uuid: 'y', 2: { _uuid: 'y.2' } }, + }) + }) + + it('skip prefixed names', async function () { + const titles = ['.something', '.something...something', 'something.a.b.c'] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + '.something': { _uuid: '.something' }, + '.something...something': { _uuid: '.something...something' }, + something: { + _uuid: '0', + a: { _uuid: '1', b: { _uuid: '2', c: { _uuid: 'something.a.b.c' } } }, + }, + }) + }) + + it('skip not-supported names', async function () { + const titles = [ + 'something.', + 'something..', + 'something..another.thing', + 'a.b.c', + 'a', + 'something..another.thing..anyway', + ] + await makeTags(this.application, titles) + + // Run the migration + await this.application.mutator.migrateTagsToFolders() + + // Check new tags + const result = extractTagHierarchy(this.application) + + expect(result).to.deep.equal({ + 'something.': { _uuid: 'something.' }, + 'something..': { _uuid: 'something..' }, + 'something..another.thing': { _uuid: 'something..another.thing' }, + 'something..another.thing..anyway': { + _uuid: 'something..another.thing..anyway', + }, + a: { + _uuid: 'a', + b: { + _uuid: '0', + c: { + _uuid: 'a.b.c', + }, + }, + }, + }) + }) +}) + +const makeTags = async (application, titles) => { + const createTag = (title) => { + return Factory.createMappedTag(application, { title, uuid: title }) + } + + const tags = await Promise.all(titles.map(createTag)) + return tags +} + +const extractTagHierarchy = (application) => { + const result = {} + const roots = application.itemManager.getRootTags() + + const constructHierarchy = (currentTag, result) => { + result[currentTag.title] = { _uuid: currentTag.uuid } + + const children = application.items.getTagChildren(currentTag) + + children.forEach((child) => { + constructHierarchy(child, result[currentTag.title]) + }) + } + + roots.forEach((tag) => { + constructHierarchy(tag, result) + }) + + return result +} diff --git a/packages/snjs/mocha/model_tests/appmodels.test.js b/packages/snjs/mocha/model_tests/appmodels.test.js new file mode 100644 index 000000000..a09b0714f --- /dev/null +++ b/packages/snjs/mocha/model_tests/appmodels.test.js @@ -0,0 +1,374 @@ +/* eslint-disable camelcase */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('app models', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + const sharedApplication = Factory.createApplicationWithFakeCrypto() + + before(async function () { + localStorage.clear() + await Factory.initializeApplication(sharedApplication) + }) + + after(async function () { + localStorage.clear() + await Factory.safeDeinit(sharedApplication) + }) + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('payloadManager should be defined', () => { + expect(sharedApplication.payloadManager).to.be.ok + }) + + it('item should be defined', () => { + expect(GenericItem).to.be.ok + }) + + it('item content should be assigned', () => { + const params = Factory.createNotePayload() + const item = CreateDecryptedItemFromPayload(params) + expect(item.content.title).to.equal(params.content.title) + }) + + it('should default updated_at to 1970 and created_at to the present', () => { + const params = Factory.createNotePayload() + const item = CreateDecryptedItemFromPayload(params) + const epoch = new Date(0) + expect(item.serverUpdatedAt - epoch).to.equal(0) + expect(item.created_at - epoch).to.be.above(0) + expect(new Date() - item.created_at).to.be.below(5) // < 5ms + }) + + it('handles delayed mapping', async function () { + const params1 = Factory.createNotePayload() + const params2 = Factory.createNotePayload() + + const mutated = new DecryptedPayload({ + ...params1, + content: { + ...params1.content, + references: [ + { + uuid: params2.uuid, + content_type: params2.content_type, + }, + ], + }, + }) + + await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + await this.application.itemManager.emitItemsFromPayloads([params2], PayloadEmitSource.LocalChanged) + + const item1 = this.application.itemManager.findItem(params1.uuid) + const item2 = this.application.itemManager.findItem(params2.uuid) + + expect(item1.content.references.length).to.equal(1) + expect(item2.content.references.length).to.equal(0) + + expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1) + }) + + it('mapping an item twice shouldnt cause problems', async function () { + const payload = Factory.createNotePayload() + const mutated = new DecryptedPayload({ + ...payload, + content: { + ...payload.content, + foo: 'bar', + }, + }) + + let items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + let item = items[0] + expect(item).to.be.ok + + items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + item = items[0] + + expect(item.content.foo).to.equal('bar') + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + }) + + it('mapping item twice should preserve references', async function () { + const item1 = await Factory.createMappedNote(this.application) + const item2 = await Factory.createMappedNote(this.application) + + await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + await this.application.itemManager.changeItem(item2, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item1) + }) + + const refreshedItem = this.application.itemManager.findItem(item1.uuid) + expect(refreshedItem.content.references.length).to.equal(1) + }) + + it('fixes relationship integrity', async function () { + var item1 = await Factory.createMappedNote(this.application) + var item2 = await Factory.createMappedNote(this.application) + + await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + await this.application.itemManager.changeItem(item2, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item1) + }) + + const refreshedItem1 = this.application.itemManager.findItem(item1.uuid) + const refreshedItem2 = this.application.itemManager.findItem(item2.uuid) + + expect(refreshedItem1.content.references.length).to.equal(1) + expect(refreshedItem2.content.references.length).to.equal(1) + + const damagedPayload = refreshedItem1.payload.copy({ + content: { + ...refreshedItem1.content, + // damage references of one object + references: [], + }, + }) + await this.application.itemManager.emitItemsFromPayloads([damagedPayload], PayloadEmitSource.LocalChanged) + + const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid) + const refreshedItem2_2 = this.application.itemManager.findItem(item2.uuid) + + expect(refreshedItem1_2.content.references.length).to.equal(0) + expect(refreshedItem2_2.content.references.length).to.equal(1) + }) + + it('creating and removing relationships between two items should have valid references', async function () { + var item1 = await Factory.createMappedNote(this.application) + var item2 = await Factory.createMappedNote(this.application) + await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + await this.application.itemManager.changeItem(item2, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item1) + }) + + const refreshedItem1 = this.application.itemManager.findItem(item1.uuid) + const refreshedItem2 = this.application.itemManager.findItem(item2.uuid) + + expect(refreshedItem1.content.references.length).to.equal(1) + expect(refreshedItem2.content.references.length).to.equal(1) + + expect(this.application.itemManager.itemsReferencingItem(item1)).to.include(refreshedItem2) + expect(this.application.itemManager.itemsReferencingItem(item2)).to.include(refreshedItem1) + + await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.removeItemAsRelationship(item2) + }) + await this.application.itemManager.changeItem(item2, (mutator) => { + mutator.removeItemAsRelationship(item1) + }) + + const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid) + const refreshedItem2_2 = this.application.itemManager.findItem(item2.uuid) + + expect(refreshedItem1_2.content.references.length).to.equal(0) + expect(refreshedItem2_2.content.references.length).to.equal(0) + + expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(0) + }) + + it('properly duplicates item with no relationships', async function () { + const item = await Factory.createMappedNote(this.application) + const duplicate = await this.application.itemManager.duplicateItem(item) + expect(duplicate.uuid).to.not.equal(item.uuid) + expect(item.isItemContentEqualWith(duplicate)).to.equal(true) + expect(item.created_at.toISOString()).to.equal(duplicate.created_at.toISOString()) + expect(item.content_type).to.equal(duplicate.content_type) + }) + + it('properly duplicates item with relationships', async function () { + const item1 = await Factory.createMappedNote(this.application) + const item2 = await Factory.createMappedNote(this.application) + + const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + + expect(refreshedItem1.content.references.length).to.equal(1) + + const duplicate = await this.application.itemManager.duplicateItem(item1) + expect(duplicate.uuid).to.not.equal(item1.uuid) + expect(duplicate.content.references.length).to.equal(1) + + expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(2) + + const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid) + expect(refreshedItem1_2.isItemContentEqualWith(duplicate)).to.equal(true) + expect(refreshedItem1_2.created_at.toISOString()).to.equal(duplicate.created_at.toISOString()) + expect(refreshedItem1_2.content_type).to.equal(duplicate.content_type) + }) + + it('removing references should update cross-refs', async function () { + const item1 = await Factory.createMappedNote(this.application) + const item2 = await Factory.createMappedNote(this.application) + const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + + const refreshedItem1_2 = await this.application.itemManager.emitItemFromPayload( + refreshedItem1.payloadRepresentation({ + deleted: true, + content: { + ...refreshedItem1.payload.content, + references: [], + }, + }), + PayloadEmitSource.LocalChanged, + ) + + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0) + expect(refreshedItem1_2.content.references.length).to.equal(0) + }) + + it('properly handles single item uuid alternation', async function () { + const item1 = await Factory.createMappedNote(this.application) + const item2 = await Factory.createMappedNote(this.application) + + const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + + expect(refreshedItem1.content.references.length).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1) + + const alternatedItem = await Factory.alternateUuidForItem(this.application, item1.uuid) + const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid) + expect(refreshedItem1_2).to.not.be.ok + + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) + + expect(alternatedItem.content.references.length).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(alternatedItem.uuid).length).to.equal(0) + + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1) + + expect(alternatedItem.isReferencingItem(item2)).to.equal(true) + expect(alternatedItem.dirty).to.equal(true) + }) + + it('alterating uuid of item should fill its duplicateOf value', async function () { + const item1 = await Factory.createMappedNote(this.application) + const alternatedItem = await Factory.alternateUuidForItem(this.application, item1.uuid) + expect(alternatedItem.duplicateOf).to.equal(item1.uuid) + }) + + it('alterating itemskey uuid should update errored items encrypted with that key', async function () { + const item1 = await Factory.createMappedNote(this.application) + const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0] + + /** Encrypt item1 and emit as errored so it persists with items_key_id */ + const encrypted = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [item1.payload], + }, + }) + const errored = encrypted.copy({ + errorDecrypting: true, + waitingForKey: true, + }) + + await this.application.itemManager.emitItemFromPayload(errored) + + expect(this.application.payloadManager.findOne(item1.uuid).errorDecrypting).to.equal(true) + expect(this.application.payloadManager.findOne(item1.uuid).items_key_id).to.equal(itemsKey.uuid) + + sinon.stub(this.application.protocolService.itemsEncryption, 'decryptErroredPayloads').callsFake(() => { + // prevent auto decryption + }) + + const alternatedKey = await Factory.alternateUuidForItem(this.application, itemsKey.uuid) + const updatedPayload = this.application.payloadManager.findOne(item1.uuid) + + expect(updatedPayload.items_key_id).to.equal(alternatedKey.uuid) + }) + + it('properly handles mutli item uuid alternation', async function () { + const item1 = await Factory.createMappedNote(this.application) + const item2 = await Factory.createMappedNote(this.application) + this.expectedItemCount += 2 + + await this.application.itemManager.changeItem(item1, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }) + + expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1) + + const alternatedItem1 = await Factory.alternateUuidForItem(this.application, item1.uuid) + const alternatedItem2 = await Factory.alternateUuidForItem(this.application, item2.uuid) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + expect(item1.uuid).to.not.equal(alternatedItem1.uuid) + expect(item2.uuid).to.not.equal(alternatedItem2.uuid) + + const refreshedAltItem1 = this.application.itemManager.findItem(alternatedItem1.uuid) + expect(refreshedAltItem1.content.references.length).to.equal(1) + expect(refreshedAltItem1.content.references[0].uuid).to.equal(alternatedItem2.uuid) + expect(alternatedItem2.content.references.length).to.equal(0) + + expect(this.application.itemManager.itemsReferencingItem(alternatedItem2).length).to.equal(1) + + expect(refreshedAltItem1.isReferencingItem(alternatedItem2)).to.equal(true) + expect(alternatedItem2.isReferencingItem(refreshedAltItem1)).to.equal(false) + expect(refreshedAltItem1.dirty).to.equal(true) + }) + + it('maintains referencing relationships when duplicating', async function () { + const tag = await Factory.createMappedTag(this.application) + const note = await Factory.createMappedNote(this.application) + const refreshedTag = await this.application.itemManager.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + }) + + expect(refreshedTag.content.references.length).to.equal(1) + + const noteCopy = await this.application.itemManager.duplicateItem(note) + expect(note.uuid).to.not.equal(noteCopy.uuid) + + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) + + expect(note.content.references.length).to.equal(0) + expect(noteCopy.content.references.length).to.equal(0) + const refreshedTag_2 = this.application.itemManager.findItem(tag.uuid) + expect(refreshedTag_2.content.references.length).to.equal(2) + }) + + it('maintains editor reference when duplicating note', async function () { + const note = await Factory.createMappedNote(this.application) + const editor = await this.application.itemManager.createItem( + ContentType.Component, + { area: ComponentArea.Editor }, + true, + ) + await this.application.itemManager.changeComponent(editor, (mutator) => { + mutator.associateWithItem(note.uuid) + }) + + expect(this.application.componentManager.editorForNote(note).uuid).to.equal(editor.uuid) + + const duplicate = await this.application.itemManager.duplicateItem(note, true) + expect(this.application.componentManager.editorForNote(duplicate).uuid).to.equal(editor.uuid) + }) +}) diff --git a/packages/snjs/mocha/model_tests/importing.test.js b/packages/snjs/mocha/model_tests/importing.test.js new file mode 100644 index 000000000..813e760cb --- /dev/null +++ b/packages/snjs/mocha/model_tests/importing.test.js @@ -0,0 +1,871 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import { createRelatedNoteTagPairPayload } from '../lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('importing', function () { + this.timeout(Factory.TenSecondTimeout) + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + let expectedItemCount + let application + let email + let password + + beforeEach(function () { + localStorage.clear() + }) + + const setup = async ({ fakeCrypto }) => { + expectedItemCount = BASE_ITEM_COUNT + if (fakeCrypto) { + application = await Factory.createInitAppWithFakeCrypto() + } else { + application = await Factory.createInitAppWithRealCrypto() + } + email = UuidGenerator.GenerateUuid() + password = UuidGenerator.GenerateUuid() + Factory.handlePasswordChallenges(application, password) + } + + afterEach(async function () { + await Factory.safeDeinit(application) + localStorage.clear() + }) + + it('should not import backups made from unsupported versions', async function () { + await setup({ fakeCrypto: true }) + const result = await application.mutator.importData({ + version: '-1', + items: [], + }) + expect(result.error).to.exist + }) + + it('should not import backups made from 004 into 003 account', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerOldUser({ + application, + email, + password, + version: ProtocolVersion.V003, + }) + const result = await application.mutator.importData({ + version: ProtocolVersion.V004, + items: [], + }) + expect(result.error).to.exist + }) + + it('importing existing data should keep relationships valid', async function () { + await setup({ fakeCrypto: true }) + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + await application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + expectedItemCount += 2 + const note = application.itemManager.getItems([ContentType.Note])[0] + const tag = application.itemManager.getItems([ContentType.Tag])[0] + + expect(tag.content.references.length).to.equal(1) + expect(tag.noteCount).to.equal(1) + + expect(note.content.references.length).to.equal(0) + expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1) + + await application.mutator.importData( + { + items: [notePayload, tagPayload], + }, + true, + ) + + expect(application.itemManager.items.length).to.equal(expectedItemCount) + + expect(tag.content.references.length).to.equal(1) + expect(tag.noteCount).to.equal(1) + + expect(note.content.references.length).to.equal(0) + expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1) + }) + + it('importing same note many times should create only one duplicate', async function () { + /** + * Used strategy here will be KEEP_LEFT_DUPLICATE_RIGHT + * which means that new right items will be created with different + */ + await setup({ fakeCrypto: true }) + const notePayload = Factory.createNotePayload() + await application.itemManager.emitItemFromPayload(notePayload, PayloadEmitSource.LocalChanged) + expectedItemCount++ + const mutatedNote = new DecryptedPayload({ + ...notePayload, + content: { + ...notePayload.content, + title: `${Math.random()}`, + }, + }) + await application.mutator.importData( + { + items: [mutatedNote, mutatedNote, mutatedNote], + }, + true, + ) + expectedItemCount++ + expect(application.itemManager.getDisplayableNotes().length).to.equal(2) + const imported = application.itemManager.getDisplayableNotes().find((n) => n.uuid !== notePayload.uuid) + expect(imported.content.title).to.equal(mutatedNote.content.title) + }) + + it('importing a tag with lesser references should not create duplicate', async function () { + await setup({ fakeCrypto: true }) + const pair = createRelatedNoteTagPairPayload() + const tagPayload = pair[1] + await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + const mutatedTag = new DecryptedPayload({ + ...tagPayload, + content: { + ...tagPayload.content, + references: [], + }, + }) + await application.mutator.importData( + { + items: [mutatedTag], + }, + true, + ) + expect(application.itemManager.getDisplayableTags().length).to.equal(1) + expect(application.itemManager.findItem(tagPayload.uuid).content.references.length).to.equal(1) + }) + + it('importing data with differing content should create duplicates', async function () { + await setup({ fakeCrypto: true }) + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + expectedItemCount += 2 + const note = application.itemManager.getDisplayableNotes()[0] + const tag = application.itemManager.getDisplayableTags()[0] + const mutatedNote = new DecryptedPayload({ + ...notePayload, + content: { + ...notePayload.content, + title: `${Math.random()}`, + }, + }) + const mutatedTag = new DecryptedPayload({ + ...tagPayload, + content: { + ...tagPayload.content, + title: `${Math.random()}`, + }, + }) + await application.mutator.importData( + { + items: [mutatedNote, mutatedTag], + }, + true, + ) + expectedItemCount += 2 + expect(application.itemManager.items.length).to.equal(expectedItemCount) + + const newNote = application.itemManager.getDisplayableNotes().find((n) => n.uuid !== notePayload.uuid) + const newTag = application.itemManager.getDisplayableTags().find((t) => t.uuid !== tagPayload.uuid) + + expect(newNote.uuid).to.not.equal(note.uuid) + expect(newTag.uuid).to.not.equal(tag.uuid) + + const refreshedTag = application.itemManager.findItem(tag.uuid) + expect(refreshedTag.content.references.length).to.equal(2) + expect(refreshedTag.noteCount).to.equal(2) + + const refreshedNote = application.itemManager.findItem(note.uuid) + expect(refreshedNote.content.references.length).to.equal(0) + expect(application.itemManager.itemsReferencingItem(refreshedNote).length).to.equal(2) + + expect(newTag.content.references.length).to.equal(1) + expect(newTag.noteCount).to.equal(1) + + expect(newNote.content.references.length).to.equal(0) + expect(application.itemManager.itemsReferencingItem(newNote).length).to.equal(1) + }) + + it('when importing items, imported values should not be used to determine if changed', async function () { + /** + * If you have a note and a tag, and the tag has 1 reference to the note, + * and you import the same two items, except modify the note value so that + * a duplicate is created, we expect only the note to be duplicated, and the + * tag not to. However, if only the note changes, and you duplicate the note, + * which causes the tag's references content to change, then when the incoming + * tag is being processed, it will also think it has changed, since our local + * value now doesn't match what's coming in. The solution is to get all values + * ahead of time before any changes are made. + */ + await setup({ fakeCrypto: true }) + const note = await Factory.createMappedNote(application) + const tag = await Factory.createMappedTag(application) + expectedItemCount += 2 + + await application.itemManager.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + }) + + const externalNote = Object.assign( + {}, + { + uuid: note.uuid, + content: note.getContentCopy(), + content_type: note.content_type, + }, + ) + externalNote.content.text = `${Math.random()}` + + const externalTag = Object.assign( + {}, + { + uuid: tag.uuid, + content: tag.getContentCopy(), + content_type: tag.content_type, + }, + ) + + await application.mutator.importData( + { + items: [externalNote, externalTag], + }, + true, + ) + expectedItemCount += 1 + + /** We expect now that the total item count is 3, not 4. */ + expect(application.itemManager.items.length).to.equal(expectedItemCount) + + const refreshedTag = application.itemManager.findItem(tag.uuid) + /** References from both items have merged. */ + expect(refreshedTag.content.references.length).to.equal(2) + }) + + it('should import decrypted data and keep items that were previously deleted', async function () { + await setup({ fakeCrypto: true }) + + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + Factory.handlePasswordChallenges(application, password) + + const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)]) + + await application.sync.sync({ awaitAll: true }) + + await application.mutator.deleteItem(note) + expect(application.items.findItem(note.uuid)).to.not.exist + + await application.mutator.deleteItem(tag) + expect(application.items.findItem(tag.uuid)).to.not.exist + + await application.mutator.importData( + { + items: [note, tag], + }, + true, + ) + + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.items.findItem(note.uuid).deleted).to.not.be.ok + + expect(application.itemManager.getDisplayableTags().length).to.equal(1) + expect(application.items.findItem(tag.uuid).deleted).to.not.be.ok + }) + + it('should duplicate notes by alternating UUIDs when dealing with conflicts during importing', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + const note = await Factory.createSyncedNote(application) + + /** Sign into another account and import the same item. It should get a different UUID. */ + application = await Factory.signOutApplicationAndReturnNew(application) + email = UuidGenerator.GenerateUuid() + Factory.handlePasswordChallenges(application, password) + + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + await application.mutator.importData( + { + items: [note.payload], + }, + true, + ) + + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.itemManager.getDisplayableNotes()[0].uuid).to.not.equal(note.uuid) + }) + + it('should maintain consistency between storage and PayloadManager after an import with conflicts', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + const note = await Factory.createSyncedNote(application) + + /** Sign into another account and import the same items. They should get a different UUID. */ + application = await Factory.signOutApplicationAndReturnNew(application) + email = UuidGenerator.GenerateUuid() + Factory.handlePasswordChallenges(application, password) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + await application.mutator.importData( + { + items: [note], + }, + true, + ) + + const storedPayloads = await application.diskStorageService.getAllRawPayloads() + expect(application.itemManager.items.length).to.equal(storedPayloads.length) + const notes = storedPayloads.filter((p) => p.content_type === ContentType.Note) + const itemsKeys = storedPayloads.filter((p) => p.content_type === ContentType.ItemsKey) + expect(notes.length).to.equal(1) + expect(itemsKeys.length).to.equal(1) + }) + + it('should import encrypted data and keep items that were previously deleted', async function () { + await setup({ fakeCrypto: true }) + + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)]) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await application.sync.sync({ awaitAll: true }) + + await application.mutator.deleteItem(note) + expect(application.items.findItem(note.uuid)).to.not.exist + + await application.mutator.deleteItem(tag) + expect(application.items.findItem(tag.uuid)).to.not.exist + + await application.mutator.importData(backupData, true) + + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.items.findItem(note.uuid).deleted).to.not.be.ok + + expect(application.itemManager.getDisplayableTags().length).to.equal(1) + expect(application.items.findItem(tag.uuid).deleted).to.not.be.ok + }) + + it('should import decrypted data and all items payload source should be FileImport', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)]) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + await application.mutator.importData(backupData, true) + + const importedNote = application.items.findItem(note.uuid) + const importedTag = application.items.findItem(tag.uuid) + + expect(importedNote.payload.source).to.be.equal(PayloadSource.FileImport) + expect(importedTag.payload.source).to.be.equal(PayloadSource.FileImport) + }) + + it('should import encrypted data and all items payload source should be FileImport', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)]) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + await application.mutator.importData(backupData, true) + + const importedNote = application.items.findItem(note.uuid) + const importedTag = application.items.findItem(tag.uuid) + expect(importedNote.payload.source).to.be.equal(PayloadSource.FileImport) + expect(importedTag.payload.source).to.be.equal(PayloadSource.FileImport) + }) + + it('should import data from 003 encrypted payload using client generated backup', async function () { + await setup({ fakeCrypto: true }) + const oldVersion = ProtocolVersion.V003 + await Factory.registerOldUser({ + application: application, + email: email, + password: password, + version: oldVersion, + }) + + const noteItem = await application.itemManager.createItem(ContentType.Note, { + title: 'Encrypted note', + text: 'On protocol version 003.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(backupData.items.length) + expect(result.errorCount).to.be.eq(0) + + const decryptedNote = application.itemManager.findItem(noteItem.uuid) + expect(decryptedNote.title).to.be.eq('Encrypted note') + expect(decryptedNote.text).to.be.eq('On protocol version 003.') + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + }) + + it('should import data from 003 encrypted payload using server generated backup with 004 key params', async function () { + await setup({ fakeCrypto: false }) + const backupData = { + items: [ + { + uuid: 'eb1b7eed-e43d-48dd-b257-b7fc8ccba3da', + duplicate_of: null, + items_key_id: null, + content: + '003:618138e365a13f8aed17d4f52e3da47d4b5d6e02004a0f827118e8a981a57c35:eb1b7eed-e43d-48dd-b257-b7fc8ccba3da:9f38642b7a3f57546520a9e32aa7c0ad:qa9rUcaD904m1Knv63dnATEHwfHJjsbq9bWb06zGTsyQxzLaAYT7uRGp2KB2g1eo5Aqxc5FqhvuF0+dE1f4+uQOeiRFNX73V2pJJY0w5Qq7l7ZuhB08ZtOMY4Ctq7evBBSIVZ+PEIfFnACelNJhsB5Uhn3kS4ZBx6qtvQ6ciSQGfYAwc6wSKhjUm1umEINeb08LNgwbP6XAm8U/la1bdtdMO112XjUW7ixkWi3POWcM=:eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiJhZmIwYjE3NGJlYjViMmJmZTIyNTk1NDlmMTgxNDI1NzlkMDE1ZmE3ZTBhMjE4YzVmNDIxNmU0Mzg2ZGI3OWFiIiwidmVyc2lvbiI6IjAwMyJ9', + content_type: 'Note', + enc_item_key: + '003:5a01e913c52899ba10c16dbe7e713dd9caf9b9554c82176ddfcf1424f5bfd94f:eb1b7eed-e43d-48dd-b257-b7fc8ccba3da:14721ff8dbdd36fb57ae4bf7414c5eab:odmq91dfaTZG/zeSUA09fD/PdB2OkiDxcQZ0FL06GPstxdvxnU17k1rtsWoA7HoNNnd5494BZ/b7YiKqUb76ddd8x3/+cTZgCa4tYxNINmb1T3wwUX0Ebxc8xynAhg6nTY/BGq+ba6jTyl8zw12dL3kBEGGglRCHnO0ZTeylwQW7asfONN8s0BwrvHdonRlx:eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiJhZmIwYjE3NGJlYjViMmJmZTIyNTk1NDlmMTgxNDI1NzlkMDE1ZmE3ZTBhMjE4YzVmNDIxNmU0Mzg2ZGI3OWFiIiwidmVyc2lvbiI6IjAwMyJ9', + auth_hash: null, + created_at: '2019-05-12T02:29:21.789000Z', + updated_at: '2019-11-12T21:47:48.382708Z', + deleted: false, + }, + { + uuid: '10051be7-4ca2-4af3-aae9-021939df4fab', + duplicate_of: null, + items_key_id: null, + content: + '004:77a986823b8ffdd87164b6f541de6ed420b70ac67e055774:+8cjww1QbyXNX+PSKeCwmnysv0rAoEaKh409VWQJpDbEy/pPZCT6c0rKxLzvyMiSq6EwkOiduZMzokRgCKP7RuRqNPJceWsxNnpIUwa40KR1IP2tdreW4J8v9pFEzPMec1oq40u+c+UI/Y6ChOLV/4ozyWmpQCK3y8Ugm7B1/FzaeDs9Ie6Mvf98+XECoi0fWv9SO2TeBvq1G24LXd4zf0j8jd0sKZbLPXH0+gaUXtBH7A56lHvB0ED9NuiHI8xopTBd9ogKlz/b5+JB4zA2zQCQ3WMEE1qz6WeB2S4FMomgeO1e3trabdU0ICu0WMvDVii4qNlQo/inD41oHXKeV5QwnYoGjPrLJIaP0hiLKhDURTHygCdvWdp63OWI+aGxv0/HI+nfcRsqSE+aYECrWB/kp/c5yTrEqBEafuWZkw==:eyJrcCI6eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X25vbmNlIjoiNjUxYWUxZWM5NTgwMzM5YTM1NjdlZTdmMGY4NjcyNDkyZGUyYzE2NmE1NTZjMTNkMTE5NzI4YTAzYzYwZjc5MyIsInZlcnNpb24iOiIwMDQiLCJvcmlnaW5hdGlvbiI6InByb3RvY29sLXVwZ3JhZGUiLCJjcmVhdGVkIjoiMTYxNDc4NDE5MjQ5NyJ9LCJ1IjoiMTAwNTFiZTctNGNhMi00YWYzLWFhZTktMDIxOTM5ZGY0ZmFiIiwidiI6IjAwNCJ9', + content_type: 'SN|ItemsKey', + enc_item_key: + '004:d25deb224251b4705a44d8ce125a62f6a2f0e0e856603e8f:FEv1pfU/VfY7XhJrTfpcdhaSBfmNySTQtHohFYDm8V84KlyF5YaXRKV7BfXsa77DKTjOCU/EHHsWwhBEEfsNnzNySHxTHNc26bpoz0V8h50=:eyJrcCI6eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X25vbmNlIjoiNjUxYWUxZWM5NTgwMzM5YTM1NjdlZTdmMGY4NjcyNDkyZGUyYzE2NmE1NTZjMTNkMTE5NzI4YTAzYzYwZjc5MyIsInZlcnNpb24iOiIwMDQiLCJvcmlnaW5hdGlvbiI6InByb3RvY29sLXVwZ3JhZGUiLCJjcmVhdGVkIjoiMTYxNDc4NDE5MjQ5NyJ9LCJ1IjoiMTAwNTFiZTctNGNhMi00YWYzLWFhZTktMDIxOTM5ZGY0ZmFiIiwidiI6IjAwNCJ9', + auth_hash: null, + created_at: '2020-09-07T12:22:06.562000Z', + updated_at: '2021-03-03T15:09:55.741107Z', + deleted: false, + }, + ], + auth_params: { + identifier: 'playground@bitar.io', + pw_nonce: '651ae1ec9580339a3567ee7f0f8672492de2c166a556c13d119728a03c60f793', + version: '004', + }, + } + + const password = 'password' + + application = await Factory.createInitAppWithRealCrypto() + Factory.handlePasswordChallenges(application, password) + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(backupData.items.length) + expect(result.errorCount).to.be.eq(0) + }) + + it('should import data from 004 encrypted payload', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + const noteItem = await application.itemManager.createItem(ContentType.Note, { + title: 'Encrypted note', + text: 'On protocol version 004.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(backupData.items.length) + expect(result.errorCount).to.be.eq(0) + + const decryptedNote = application.itemManager.findItem(noteItem.uuid) + expect(decryptedNote.title).to.be.eq('Encrypted note') + expect(decryptedNote.text).to.be.eq('On protocol version 004.') + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + }) + + it('should return correct errorCount', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + const noteItem = await application.itemManager.createItem(ContentType.Note, { + title: 'This is a valid, encrypted note', + text: 'On protocol version 004.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + const madeUpPayload = JSON.parse(JSON.stringify(noteItem)) + + madeUpPayload.items_key_id = undefined + madeUpPayload.content = '004:somenonsense' + madeUpPayload.enc_item_key = '003:anothernonsense' + madeUpPayload.version = '004' + madeUpPayload.uuid = 'fake-uuid' + + backupData.items = [...backupData.items, madeUpPayload] + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(backupData.items.length - 1) + expect(result.errorCount).to.be.eq(1) + }) + + it('should not import data from 003 encrypted payload if an invalid password is provided', async function () { + await setup({ fakeCrypto: true }) + + const oldVersion = ProtocolVersion.V003 + await Factory.registerOldUser({ + application: application, + email: email, + password: UuidGenerator.GenerateUuid(), + version: oldVersion, + }) + + await application.itemManager.createItem(ContentType.Note, { + title: 'Encrypted note', + text: 'On protocol version 003.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + application.setLaunchCallback({ + receiveChallenge: (challenge) => { + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.None ? 'incorrect password' : password, + ), + ) + application.submitValuesForChallenge(challenge, values) + }, + }) + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + + expect(result.affectedItems.length).to.be.eq(0) + expect(result.errorCount).to.be.eq(backupData.items.length) + expect(application.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('should not import data from 004 encrypted payload if an invalid password is provided', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + await application.itemManager.createItem(ContentType.Note, { + title: 'This is a valid, encrypted note', + text: 'On protocol version 004.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + application.setLaunchCallback({ + receiveChallenge: (challenge) => { + const values = challenge.prompts.map((prompt) => CreateChallengeValue(prompt, 'incorrect password')) + application.submitValuesForChallenge(challenge, values) + }, + }) + + const result = await application.mutator.importData(backupData, true) + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(0) + expect(result.errorCount).to.be.eq(backupData.items.length) + expect(application.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('should not import encrypted data with no keyParams or auth_params', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + await application.itemManager.createItem(ContentType.Note, { + title: 'Encrypted note', + text: 'On protocol version 004.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + delete backupData.keyParams + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + + const result = await application.mutator.importData(backupData) + + expect(result.error).to.be.ok + }) + + it('should not import payloads if the corresponding ItemsKey is not present within the backup file', async function () { + await setup({ fakeCrypto: true }) + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + Factory.handlePasswordChallenges(application, password) + + await application.itemManager.createItem(ContentType.Note, { + title: 'Encrypted note', + text: 'On protocol version 004.', + }) + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + backupData.items = backupData.items.filter((payload) => payload.content_type !== ContentType.ItemsKey) + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + const result = await application.mutator.importData(backupData, true) + + expect(result).to.not.be.undefined + expect(result.affectedItems.length).to.be.eq(0) + expect(result.errorCount).to.be.eq(backupData.items.length) + expect(application.itemManager.getDisplayableNotes().length).to.equal(0) + }) + + it('importing data with no items key should use the root key generated by the file password', async function () { + await setup({ fakeCrypto: false }) + /** + * In SNJS 2.0.12, this file import would fail with "incorrect password" on file. + * The reason was that we would use the default items key we had for the current account + * instead of using the password generated root key for the file. + * + * Note this test will not be able to properly sync as the credentials are invalid. + * This test is only meant to test successful local importing. + */ + const identifier = 'standardnotes' + const application = await Factory.createApplicationWithRealCrypto(identifier) + /** Create legacy migrations value so that base migration detects old app */ + await application.deviceInterface.setRawStorageValue( + 'keychain', + JSON.stringify({ + [identifier]: { + version: '003', + masterKey: '30bae65687b45b20100be219df983bded23868baa44f4bbef1026403daee0a9d', + dataAuthenticationKey: 'c9b382ff1f7adb5c6cad620605ad139cd9f1e7700f507345ef1a1d46a6413712', + }, + }), + ) + await application.deviceInterface.setRawStorageValue( + 'descriptors', + JSON.stringify({ + [identifier]: { + identifier: 'standardnotes', + label: 'Main Application', + primary: true, + }, + }), + ) + await application.deviceInterface.setRawStorageValue('standardnotes-snjs_version', '2.0.11') + await application.deviceInterface.saveRawDatabasePayload( + { + content: + '003:9f2c7527eb8b2a1f8bfb3ea6b885403b6886bce2640843ebd57a6c479cbf7597:58e3322b-269a-4be3-a658-b035dffcd70f:9140b23a0fa989e224e292049f133154:SESTNOgIGf2+ZqmJdFnGU4EMgQkhKOzpZNoSzx76SJaImsayzctAgbUmJ+UU2gSQAHADS3+Z5w11bXvZgIrStTsWriwvYkNyyKmUPadKHNSBwOk4WeBZpWsA9gtI5zgI04Q5pvb8hS+kNW2j1DjM4YWqd0JQxMOeOrMIrxr/6Awn5TzYE+9wCbXZdYHyvRQcp9ui/G02ZJ67IA86vNEdjTTBAAWipWqTqKH9VDZbSQ2W/IOKfIquB373SFDKZb1S1NmBFvcoG2G7w//fAl/+ehYiL6UdiNH5MhXCDAOTQRFNfOh57HFDWVnz1VIp8X+VAPy6d9zzQH+8aws1JxHq/7BOhXrFE8UCueV6kERt9njgQxKJzd9AH32ShSiUB9X/sPi0fUXbS178xAZMJrNx3w==:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=', + content_type: 'SN|ItemsKey', + created_at: new Date(), + enc_item_key: + '003:d7267919b07864ccc1da87a48db6c6192e2e892be29ce882e981c36f673b3847:58e3322b-269a-4be3-a658-b035dffcd70f:2384a22d8f8bf671ba6517c6e1d0be30:0qXjBDPLCcMlNTnuUDcFiJPIXU9OP6b4ttTVE58n2Jn7971xMhx6toLbAZWWLPk/ezX/19EYE9xmRngWsG4jJaZMxGZIz/melU08K7AHH3oahQpHwZvSM3iV2ufsN7liQywftdVH6NNzULnZnFX+FgEfpDquru++R4aWDLvsSegWYmde9zD62pPNUB9Kik6P:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=', + updated_at: new Date(), + uuid: '58e3322b-269a-4be3-a658-b035dffcd70f', + }, + identifier, + ) + /** + * Note that this storage contains "sync.standardnotes.org" as the API Host param. + */ + await application.deviceInterface.setRawStorageValue( + 'standardnotes-storage', + JSON.stringify({ + wrapped: { + uuid: '15af096f-4e9d-4cde-8d67-f132218fa757', + content_type: 'SN|EncryptedStorage', + enc_item_key: + '003:2fb0c55859ddf0c16982b91d6202a6fb8174f711d820f8b785c558538cda5048:15af096f-4e9d-4cde-8d67-f132218fa757:09a4da52d5214e76642f0363246daa99:zt5fnmxYSZOqC+uA08oAKdtjfTdAoX1lPnbTe98CYQSlIvaePIpG5c9tAN5QzZbECkj4Lm9txwSA2O6Y4Y25rqO4lIerKjxxNqPwDze9mtPOGeoR48csUPiMIHiH78bLGZZs4VoBwYKAP+uEygXEFYRuscGnDOrFV7fnwGDL/nkhr6xpM159OTUKBgiBpVMS:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=', + content: + '003:70a02948696e09211cfd34cd312dbbf85751397189da06d7acc7c46dafa9aeeb:15af096f-4e9d-4cde-8d67-f132218fa757:b92fb4b030ac51f4d3eef0ada35f3d5f:r3gdrawyd069qOQQotD5EtabTwjs4IiLFWotK0Ygbt9oAT09xILx7v92z8YALJ6i6EKHOT7zyCytR5l2B9b1J7Tls00uVgfEKs3zX7n3F6ne+ju0++WsJuy0Gre5+Olov6lqQrY3I8hWQShxaG84huZaFTIPU5+LP0JAseWWDENqUQ+Vxr+w0wqNYO6TLtr/YAqk2yOY7DLQ0WhGzK+WH9JfvS8MCccJVeBD99ebM8lKVVfTaUfrk2AlbMv47TFSjTeCDblQuU68joE45HV8Y0g2CF4nkTvdr3wn0HhdDp07YuXditX9NGtBhI8oFkstwKEksblyX9dGpn7of4ctdvNOom3Vjw/m4x9mE0lCIbjxQVAiDyy+Hg0HDtVt1j205ycg1RS7cT7+Sn746Z06S8TixcVUUUQh+MGRIulIE5utOE81Lv/p+jb2vmv+TGHUV4kZJPluG7A9IEphMZrMWwiU56FdSlSDD82qd9iG+C3Pux+X/GYCMiWS2T/BoyI6a9OERSARuTUuom2bv59hqD1yUoj7VQXhqXmverSwLE1zDeF+dc0tMwuTNCNOTk08A6wRKTR9ZjuFlLcxHsg/VZyfIdCkElFh1FrliMbW2ZsgsPFaZAI+YN8pid1tTw+Ou1cOfyD85aki98DDvg/cTi8ahrrm8UvxRQwhIW17Cm1RnKxhIvaq5HRjEN76Y46ubkZv7/HjhNwJt9vPEr9wyOrMH6XSxCnSIFD1kbVHI33q444xyUWa/EQju8SoEGGU92HhpMWd1kIz37SJRJTC7u2ah2Xg60JGcUcCNtHG3IHMPVP+UKUjx5nKP6t/NVSa+xsjIvM/ZkSL37W0TMZykC1cKfzeUmlZhGQPCIqad3b4ognZ48LGCgwBP87rWn8Ln8Cqcz7X0Ze22HoouKBPAtWlYJ8fmvg2HiW6nX/L9DqoxK4OXt/LnC2BTEvtP4PUzBqx8WoqmVNNnYp+FgYptLcgxmgckle41w1eMr6NYGeaaC1Jk3i/e9Piw0w0XjV/lB+yn03gEMYPTT2yiXMQrfPmkUNYNN7/xfhY3bqqwfER7iXdr/80Lc+x9byywChXLvg8VCjHWGd+Sky3NHyMdxLY8IqefyyZWMeXtt1aNYH6QW9DeK5KvK3DI+MK3kWwMCySe51lkE9jzcqrxpYMZjb2Za9VDZNBgdwQYXfOlxFEje0so0LlMJmmxRfbMU06bYt0vszT2szAkOnVuyi6TBRiGLyjMxYI0csM0SHZWZUQK0z7ZoQAWR5D+adX29tOvrKc2kJA8Lrzgeqw/rJIh6zPg3kmsd2rFbo+Qfe3J6XrlZU+J+N96I98i0FU0quI6HwG1zFg6UOmfRjaCML8rSAPtMaNhlO7M2sgRmDCtsNcpU06Fua6F2fEHPiXs4+9:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=', + created_at: '2020-11-24T00:53:42.057Z', + updated_at: '1970-01-01T00:00:00.000Z', + }, + nonwrapped: { + ROOT_KEY_PARAMS: { + pw_nonce: '4cb103aa89cff4563a911d3f396583cefc6833c66f880fbee06bda94c31f868b', + pw_cost: 110000, + identifier: 'nov2322@bitar.io', + version: '003', + }, + }, + }), + ) + const password = 'password' + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + if (challenge.prompts.length === 2) { + application.submitValuesForChallenge( + challenge, + challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation !== ChallengeValidation.ProtectionSessionDuration + ? password + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ), + ) + } else { + const prompt = challenge.prompts[0] + application.submitValuesForChallenge(challenge, [CreateChallengeValue(prompt, password)]) + } + }, + }) + await application.launch(false) + await application.setHost(Factory.getDefaultHost()) + + const backupFile = { + items: [ + { + uuid: '11204d02-5a8b-47c0-ab94-ae0727d656b5', + content_type: 'Note', + created_at: '2020-11-23T17:11:06.322Z', + enc_item_key: + '003:111edcff9ed3432b9e11c4a64bef9e810ed2b9147790963caf6886511c46bbc4:11204d02-5a8b-47c0-ab94-ae0727d656b5:62de2b95cca4d7948f70516d12f5cb3a:lhUF/EoQP2DC8CSVrXyLp1yXsiJUXxwmtkwXtLUJ5sm4E0+ZNzMCO9U9ho+q6i9V+777dSbfTqODz4ZSt6hj3gtYxi9ZlOM/VrTtmJ2YcxiMaRTVl5sVZPG+YTpQPMuugN5/0EfuT/SJ9IqVbjgYhKA5xt/lMgw4JSbiW8ZkVQ5tVDfgt0omhDRLlkh758ou:eyJwd19ub25jZSI6IjNlMzU3YzQxZmI1YWU2MTUyYmZmMzY2ZjBhOGE3ZjRmZDk2NDQxZDZhNWViYzY3MDA4OTk2ZWY2YzU1YTg3ZjIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzVAYml0YXIuaW8iLCJ2ZXJzaW9uIjoiMDAzIn0=', + content: + '003:d43c6d2dc9465796e01145843cf1b95031030c15cc79a73f14d941d15e28147a:11204d02-5a8b-47c0-ab94-ae0727d656b5:84a2b760019a62d7ad9c314bc7a5564a:G8Mm9fy9ybuo92VbV4NUERruJ1VA7garv1+fBg4KRDRjsRGoLvORhHldQHRfUQmSR6PkrG6ol/jOn1gjIH5gtgGczB5NgbKau7amYZHsQJPr1UleJVsLrjMJgiYGqbEDmXPtJSX2tLGFhAbYcVX4xrHKbkiuLQnu9bZp9zbR6txB1NtLoNFvwDZTMko7Q+28fM4TKBbQCCw3NufLHVUnfEwS7tLLFFPdEyyMXOerKP93u8X+7NG2eDmsUetPsPOq:eyJwd19ub25jZSI6IjNlMzU3YzQxZmI1YWU2MTUyYmZmMzY2ZjBhOGE3ZjRmZDk2NDQxZDZhNWViYzY3MDA4OTk2ZWY2YzU1YTg3ZjIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzVAYml0YXIuaW8iLCJ2ZXJzaW9uIjoiMDAzIn0=', + auth_hash: null, + updated_at: '2020-11-23T17:11:40.399Z', + }, + ], + auth_params: { + pw_nonce: '3e357c41fb5ae6152bff366f0a8a7f4fd96441d6a5ebc67008996ef6c55a87f2', + pw_cost: 110000, + identifier: 'nov235@bitar.io', + version: '003', + }, + } + + const result = await application.mutator.importData(backupFile, false) + expect(result.errorCount).to.equal(0) + await Factory.safeDeinit(application) + }) + + it('importing another accounts notes/tags should correctly keep relationships', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await setup({ fakeCrypto: true }) + + await Factory.registerUserToApplication({ + application: application, + email: email, + password: password, + }) + + Factory.handlePasswordChallenges(application, password) + + const pair = createRelatedNoteTagPairPayload() + await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + + await application.sync.sync() + + const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups() + + await Factory.safeDeinit(application) + application = await Factory.createInitAppWithFakeCrypto() + Factory.handlePasswordChallenges(application, password) + + await Factory.registerUserToApplication({ + application: application, + email: `${Math.random()}`, + password: password, + }) + + await application.mutator.importData(backupData, true) + + expect(application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(application.itemManager.getDisplayableTags().length).to.equal(1) + + const importedNote = application.itemManager.getDisplayableNotes()[0] + const importedTag = application.itemManager.getDisplayableTags()[0] + expect(application.itemManager.referencesForItem(importedTag).length).to.equal(1) + expect(application.itemManager.itemsReferencingItem(importedNote).length).to.equal(1) + }) +}) diff --git a/packages/snjs/mocha/model_tests/items.test.js b/packages/snjs/mocha/model_tests/items.test.js new file mode 100644 index 000000000..e12388ef6 --- /dev/null +++ b/packages/snjs/mocha/model_tests/items.test.js @@ -0,0 +1,220 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('items', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('setting an item as dirty should update its client updated at', async function () { + const params = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + const item = this.application.itemManager.items[0] + const prevDate = item.userModifiedDate.getTime() + await Factory.sleep(0.1) + await this.application.itemManager.setItemDirty(item, true) + const refreshedItem = this.application.itemManager.findItem(item.uuid) + const newDate = refreshedItem.userModifiedDate.getTime() + expect(prevDate).to.not.equal(newDate) + }) + + it('setting an item as dirty with option to skip client updated at', async function () { + const params = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + const item = this.application.itemManager.items[0] + const prevDate = item.userModifiedDate.getTime() + await Factory.sleep(0.1) + await this.application.itemManager.setItemDirty(item) + const newDate = item.userModifiedDate.getTime() + expect(prevDate).to.equal(newDate) + }) + + it('properly pins, archives, and locks', async function () { + const params = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged) + + const item = this.application.itemManager.items[0] + expect(item.pinned).to.not.be.ok + + const refreshedItem = await this.application.mutator.changeAndSaveItem( + item, + (mutator) => { + mutator.pinned = true + mutator.archived = true + mutator.locked = true + }, + undefined, + undefined, + syncOptions, + ) + expect(refreshedItem.pinned).to.equal(true) + expect(refreshedItem.archived).to.equal(true) + expect(refreshedItem.locked).to.equal(true) + }) + + it('properly compares item equality', async function () { + const params1 = Factory.createNotePayload() + const params2 = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) + + let item1 = this.application.itemManager.getDisplayableNotes()[0] + let item2 = this.application.itemManager.getDisplayableNotes()[1] + + expect(item1.isItemContentEqualWith(item2)).to.equal(true) + + // items should ignore this field when checking for equality + item1 = await this.application.mutator.changeAndSaveItem( + item1, + (mutator) => { + mutator.userModifiedDate = new Date() + }, + undefined, + undefined, + syncOptions, + ) + item2 = await this.application.mutator.changeAndSaveItem( + item2, + (mutator) => { + mutator.userModifiedDate = undefined + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.isItemContentEqualWith(item2)).to.equal(true) + + item1 = await this.application.mutator.changeAndSaveItem( + item1, + (mutator) => { + mutator.mutableContent.foo = 'bar' + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.isItemContentEqualWith(item2)).to.equal(false) + + item2 = await this.application.mutator.changeAndSaveItem( + item2, + (mutator) => { + mutator.mutableContent.foo = 'bar' + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.isItemContentEqualWith(item2)).to.equal(true) + expect(item2.isItemContentEqualWith(item1)).to.equal(true) + + item1 = await this.application.mutator.changeAndSaveItem( + item1, + (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item2) + }, + undefined, + undefined, + syncOptions, + ) + item2 = await this.application.mutator.changeAndSaveItem( + item2, + (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(item1) + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.content.references.length).to.equal(1) + expect(item2.content.references.length).to.equal(1) + + expect(item1.isItemContentEqualWith(item2)).to.equal(false) + + item1 = await this.application.mutator.changeAndSaveItem( + item1, + (mutator) => { + mutator.removeItemAsRelationship(item2) + }, + undefined, + undefined, + syncOptions, + ) + item2 = await this.application.mutator.changeAndSaveItem( + item2, + (mutator) => { + mutator.removeItemAsRelationship(item1) + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.isItemContentEqualWith(item2)).to.equal(true) + expect(item1.content.references.length).to.equal(0) + expect(item2.content.references.length).to.equal(0) + }) + + it('content equality should not have side effects', async function () { + const params1 = Factory.createNotePayload() + const params2 = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged) + + let item1 = this.application.itemManager.getDisplayableNotes()[0] + const item2 = this.application.itemManager.getDisplayableNotes()[1] + + item1 = await this.application.mutator.changeAndSaveItem( + item1, + (mutator) => { + mutator.mutableContent.foo = 'bar' + }, + undefined, + undefined, + syncOptions, + ) + + expect(item1.content.foo).to.equal('bar') + + item1.contentKeysToIgnoreWhenCheckingEquality = () => { + return ['foo'] + } + + item2.contentKeysToIgnoreWhenCheckingEquality = () => { + return ['foo'] + } + + // calling isItemContentEqualWith should not have side effects + // There was an issue where calling that function would modify values directly to omit keys + // in contentKeysToIgnoreWhenCheckingEquality. + + await this.application.itemManager.setItemsDirty([item1, item2]) + + expect(item1.userModifiedDate).to.be.ok + expect(item2.userModifiedDate).to.be.ok + + expect(item1.isItemContentEqualWith(item2)).to.equal(true) + expect(item2.isItemContentEqualWith(item1)).to.equal(true) + + expect(item1.userModifiedDate).to.be.ok + expect(item2.userModifiedDate).to.be.ok + + expect(item1.content.foo).to.equal('bar') + }) +}) diff --git a/packages/snjs/mocha/model_tests/mapping.test.js b/packages/snjs/mocha/model_tests/mapping.test.js new file mode 100644 index 000000000..0e25ee494 --- /dev/null +++ b/packages/snjs/mocha/model_tests/mapping.test.js @@ -0,0 +1,127 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import { createNoteParams } from '../lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('model manager mapping', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('mapping nonexistent item creates it', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + this.expectedItemCount++ + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('mapping nonexistent deleted item doesnt create it', async function () { + const payload = new DeletedPayload({ + ...createNoteParams(), + dirty: false, + deleted: true, + }) + await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('mapping and deleting nonexistent item creates and deletes it', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + + this.expectedItemCount++ + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + const changedParams = new DeletedPayload({ + ...payload, + dirty: false, + deleted: true, + }) + + this.expectedItemCount-- + + await this.application.itemManager.emitItemsFromPayloads([changedParams], PayloadEmitSource.LocalChanged) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('mapping deleted but dirty item should not delete it', async function () { + const payload = Factory.createNotePayload() + + const [item] = await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + + this.expectedItemCount++ + + await this.application.itemManager.emitItemFromPayload(new DeleteItemMutator(item).getDeletedResult()) + + const payload2 = new DeletedPayload(this.application.payloadManager.findOne(payload.uuid).ejected()) + + await this.application.itemManager.emitItemsFromPayloads([payload2], PayloadEmitSource.LocalChanged) + + expect(this.application.payloadManager.collection.all().length).to.equal(this.expectedItemCount) + }) + + it('mapping existing item updates its properties', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + + const newTitle = 'updated title' + const mutated = new DecryptedPayload({ + ...payload, + content: { + ...payload.content, + title: newTitle, + }, + }) + await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged) + const item = this.application.itemManager.getDisplayableNotes()[0] + + expect(item.content.title).to.equal(newTitle) + }) + + it('setting an item dirty should retrieve it in dirty items', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getDisplayableNotes()[0] + await this.application.itemManager.setItemDirty(note) + const dirtyItems = this.application.itemManager.getDirtyItems() + expect(dirtyItems.length).to.equal(1) + }) + + it('set all items dirty', async function () { + const count = 10 + this.expectedItemCount += count + const payloads = [] + for (let i = 0; i < count; i++) { + payloads.push(Factory.createNotePayload()) + } + await this.application.itemManager.emitItemsFromPayloads(payloads, PayloadEmitSource.LocalChanged) + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + + const dirtyItems = this.application.itemManager.getDirtyItems() + expect(dirtyItems.length).to.equal(this.expectedItemCount) + }) + + it('sync observers should be notified of changes', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + const item = this.application.itemManager.items[0] + return new Promise((resolve) => { + this.application.itemManager.addObserver(ContentType.Any, ({ changed }) => { + expect(changed[0].uuid === item.uuid) + resolve() + }) + this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged) + }) + }) +}) diff --git a/packages/snjs/mocha/model_tests/notes_smart_tags.test.js b/packages/snjs/mocha/model_tests/notes_smart_tags.test.js new file mode 100644 index 000000000..b16700250 --- /dev/null +++ b/packages/snjs/mocha/model_tests/notes_smart_tags.test.js @@ -0,0 +1,75 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +const generateLongString = (minLength = 600) => { + const BASE = 'Lorem ipsum dolor sit amet. ' + const repeatCount = Math.ceil(minLength / BASE.length) + return BASE.repeat(repeatCount) +} + +const getFilteredNotes = (application, { views }) => { + const criteria = { + views, + includePinned: true, + } + application.items.setPrimaryItemDisplayOptions(criteria) + const notes = application.items.getDisplayableNotes() + return notes +} + +const titles = (items) => { + return items.map((item) => item.title).sort() +} + +describe('notes and smart views', () => { + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('lets me create a smart view and use it', async function () { + // ## The user creates 3 notes + const [note_1, note_2, note_3] = await Promise.all([ + Factory.createMappedNote(this.application, 'long & pinned', generateLongString()), + Factory.createMappedNote(this.application, 'long & !pinned', generateLongString()), + Factory.createMappedNote(this.application, 'pinned', 'this is a pinned note'), + ]) + + // The user pin 2 notes + await Promise.all([Factory.pinNote(this.application, note_1), Factory.pinNote(this.application, note_3)]) + + // ## The user creates smart views (long & pinned) + const not_pinned = '!["Not Pinned", "pinned", "=", false]' + const long = '!["Long", "text.length", ">", 500]' + + const tag_not_pinned = await this.application.mutator.createTagOrSmartView(not_pinned) + const tag_long = await this.application.mutator.createTagOrSmartView(long) + + // ## The user can filter and see the pinned notes + const notes_not_pinned = getFilteredNotes(this.application, { + views: [tag_not_pinned], + }) + + expect(titles(notes_not_pinned)).to.eql(['long & !pinned']) + + // ## The user can filter and see the long notes + const notes_long = getFilteredNotes(this.application, { views: [tag_long] }) + expect(titles(notes_long)).to.eql(['long & !pinned', 'long & pinned']) + + // ## The user creates a new long note + await Factory.createMappedNote(this.application, 'new long', generateLongString()) + + // ## The user can filter and see the new long note + const notes_long2 = getFilteredNotes(this.application, { + views: [tag_long], + }) + expect(titles(notes_long2)).to.eql(['long & !pinned', 'long & pinned', 'new long']) + }) +}) diff --git a/packages/snjs/mocha/model_tests/notes_tags.test.js b/packages/snjs/mocha/model_tests/notes_tags.test.js new file mode 100644 index 000000000..1aa0a927d --- /dev/null +++ b/packages/snjs/mocha/model_tests/notes_tags.test.js @@ -0,0 +1,846 @@ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import * as Utils from '../lib/Utils.js' +import { createRelatedNoteTagPairPayload } from '../lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('notes and tags', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('uses proper class for note', async function () { + const payload = Factory.createNotePayload() + await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + expect(note.constructor === SNNote).to.equal(true) + }) + + it('properly constructs syncing params', async function () { + const title = 'Foo' + const text = 'Bar' + const note = await this.application.mutator.createTemplateItem(ContentType.Note, { + title, + text, + }) + + expect(note.content.title).to.equal(title) + expect(note.content.text).to.equal(text) + + const tag = await this.application.mutator.createTemplateItem(ContentType.Tag, { + title, + }) + + expect(tag.title).to.equal(title) + }) + + it('properly handles legacy relationships', async function () { + // legacy relationships are when a note has a reference to a tag + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + const mutatedTag = new DecryptedPayload({ + ...tagPayload, + content: { + ...tagPayload.content, + references: null, + }, + }) + const mutatedNote = new DecryptedPayload({ + ...notePayload, + content: { + references: [ + { + uuid: tagPayload.uuid, + content_type: tagPayload.content_type, + }, + ], + }, + }) + + await this.application.itemManager.emitItemsFromPayloads([mutatedNote, mutatedTag], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + const tag = this.application.itemManager.getItems([ContentType.Tag])[0] + + expect(note.content.references.length).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(tag).length).to.equal(1) + }) + + it('creates relationship between note and tag', async function () { + const pair = createRelatedNoteTagPairPayload({ dirty: false }) + const notePayload = pair[0] + const tagPayload = pair[1] + + expect(notePayload.content.references.length).to.equal(0) + expect(tagPayload.content.references.length).to.equal(1) + + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getDisplayableNotes()[0] + let tag = this.application.itemManager.getDisplayableTags()[0] + + expect(note.dirty).to.not.be.ok + expect(tag.dirty).to.not.be.ok + + expect(note.content.references.length).to.equal(0) + expect(tag.content.references.length).to.equal(1) + + expect(note.isReferencingItem(tag)).to.equal(false) + expect(tag.isReferencingItem(note)).to.equal(true) + + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) + expect(note.payload.references.length).to.equal(0) + expect(tag.noteCount).to.equal(1) + + await this.application.itemManager.setItemToBeDeleted(note) + + tag = this.application.itemManager.getDisplayableTags()[0] + + const deletedNotePayload = this.application.payloadManager.findOne(note.uuid) + expect(deletedNotePayload.dirty).to.be.true + expect(tag.dirty).to.be.true + + await this.application.syncService.sync(syncOptions) + + expect(tag.content.references.length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0) + expect(tag.noteCount).to.equal(0) + + tag = this.application.itemManager.getDisplayableTags()[0] + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(0) + expect(tag.dirty).to.be.false + }) + + it('handles remote deletion of relationship', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getItems([ContentType.Note])[0] + let tag = this.application.itemManager.getItems([ContentType.Tag])[0] + + expect(note.content.references.length).to.equal(0) + expect(tag.content.references.length).to.equal(1) + + await this.application.syncService.sync(syncOptions) + + const mutatedTag = new DecryptedPayload({ + ...tagPayload, + dirty: false, + content: { + ...tagPayload.content, + references: [], + }, + }) + await this.application.itemManager.emitItemsFromPayloads([mutatedTag], PayloadEmitSource.LocalChanged) + + note = this.application.itemManager.findItem(note.uuid) + tag = this.application.itemManager.findItem(tag.uuid) + + expect(tag.content.references.length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0) + expect(tag.noteCount).to.equal(0) + + // expect to be false + expect(note.dirty).to.not.be.ok + expect(tag.dirty).to.not.be.ok + }) + + it('creating basic note should have text set', async function () { + const note = await Factory.createMappedNote(this.application) + expect(note.title).to.be.ok + expect(note.text).to.be.ok + }) + + it('creating basic tag should have title', async function () { + const tag = await Factory.createMappedTag(this.application) + expect(tag.title).to.be.ok + }) + + it('handles removing relationship between note and tag', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + let tag = this.application.itemManager.getItems([ContentType.Tag])[0] + + expect(note.content.references.length).to.equal(0) + expect(tag.content.references.length).to.equal(1) + + tag = await this.application.mutator.changeAndSaveItem( + tag, + (mutator) => { + mutator.removeItemAsRelationship(note) + }, + undefined, + undefined, + syncOptions, + ) + + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0) + expect(tag.noteCount).to.equal(0) + }) + + it('properly handles tag duplication', async function () { + const pair = createRelatedNoteTagPairPayload() + await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getDisplayableNotes()[0] + let tag = this.application.itemManager.getDisplayableTags()[0] + + const duplicateTag = await this.application.itemManager.duplicateItem(tag, true) + await this.application.syncService.sync(syncOptions) + + note = this.application.itemManager.findItem(note.uuid) + tag = this.application.itemManager.findItem(tag.uuid) + + expect(tag.uuid).to.not.equal(duplicateTag.uuid) + expect(tag.content.references.length).to.equal(1) + expect(tag.noteCount).to.equal(1) + expect(duplicateTag.content.references.length).to.equal(1) + expect(duplicateTag.noteCount).to.equal(1) + + const noteTags = this.application.itemManager.itemsReferencingItem(note) + expect(noteTags.length).to.equal(2) + + const noteTag1 = noteTags[0] + const noteTag2 = noteTags[1] + expect(noteTag1.uuid).to.not.equal(noteTag2.uuid) + + // expect to be false + expect(note.dirty).to.not.be.ok + expect(tag.dirty).to.not.be.ok + }) + + it('duplicating a note should maintain its tag references', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + const duplicateNote = await this.application.itemManager.duplicateItem(note, true) + expect(note.uuid).to.not.equal(duplicateNote.uuid) + + expect(this.application.itemManager.itemsReferencingItem(duplicateNote).length).to.equal( + this.application.itemManager.itemsReferencingItem(note).length, + ) + }) + + it('deleting a note should update tag references', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + let tag = this.application.itemManager.getItems([ContentType.Tag])[0] + + expect(tag.content.references.length).to.equal(1) + expect(tag.noteCount).to.equal(1) + + expect(note.content.references.length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) + + await this.application.itemManager.setItemToBeDeleted(tag) + tag = this.application.itemManager.findItem(tag.uuid) + expect(tag).to.not.be.ok + }) + + it('modifying item content should not modify payload content', async function () { + const notePayload = Factory.createNotePayload() + await this.application.itemManager.emitItemsFromPayloads([notePayload], PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getItems([ContentType.Note])[0] + note = await this.application.mutator.changeAndSaveItem( + note, + (mutator) => { + mutator.mutableContent.title = Math.random() + }, + undefined, + undefined, + syncOptions, + ) + expect(note.content.title).to.not.equal(notePayload.content.title) + }) + + it('deleting a tag should not dirty notes', async function () { + // Tags now reference notes, but it used to be that tags referenced notes and notes referenced tags. + // After the change, there was an issue where removing an old tag relationship from a note would only + // remove one way, and thus keep it intact on the visual level. + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getItems([ContentType.Note])[0] + let tag = this.application.itemManager.getItems([ContentType.Tag])[0] + + await this.application.syncService.sync(syncOptions) + await this.application.itemManager.setItemToBeDeleted(tag) + + note = this.application.itemManager.findItem(note.uuid) + this.application.itemManager.findItem(tag.uuid) + + expect(note.dirty).to.not.be.ok + }) + + it('should sort notes', async function () { + await Promise.all( + ['Y', 'Z', 'A', 'B'].map(async (title) => { + return this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { title }), + ) + }), + ) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + }) + const titles = this.application.items.getDisplayableNotes().map((note) => note.title) + /** setPrimaryItemDisplayOptions inverses sort for title */ + expect(titles).to.deep.equal(['A', 'B', 'Y', 'Z']) + }) + + it('setting a note dirty should collapse its properties into content', async function () { + let note = await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'Foo', + }) + await this.application.mutator.insertItem(note) + note = this.application.itemManager.findItem(note.uuid) + expect(note.content.title).to.equal('Foo') + }) + + describe('Tags', function () { + it('should sort tags in ascending alphabetical order by default', async function () { + const titles = ['1', 'A', 'b', '2'] + const sortedTitles = titles.sort((a, b) => a.localeCompare(b)) + await Promise.all(titles.map((title) => this.application.mutator.findOrCreateTag(title))) + expect(this.application.items.tagDisplayController.items().map((t) => t.title)).to.deep.equal(sortedTitles) + }) + + it('should match a tag', async function () { + const taggedNote = await Factory.createMappedNote(this.application) + const tag = await this.application.mutator.findOrCreateTag('A') + await this.application.mutator.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) + }) + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + tags: [tag], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes.length).to.equal(1) + expect(displayedNotes[0].uuid).to.equal(taggedNote.uuid) + }) + + it('should not show trashed notes when displaying a tag', async function () { + const taggedNote = await Factory.createMappedNote(this.application) + const trashedNote = await Factory.createMappedNote(this.application) + const tag = await this.application.mutator.findOrCreateTag('A') + await this.application.mutator.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) + mutator.e2ePendingRefactor_addItemAsRelationship(trashedNote) + }) + await this.application.mutator.changeItem(trashedNote, (mutator) => { + mutator.trashed = true + }) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + tags: [tag], + includeTrashed: false, + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes.length).to.equal(1) + expect(displayedNotes[0].uuid).to.equal(taggedNote.uuid) + }) + + it('should sort notes when displaying tag', async function () { + await Promise.all( + ['Y', 'Z', 'A', 'B'].map(async (title) => { + return this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title, + }), + ) + }), + ) + const pinnedNote = this.application.itemManager.getDisplayableNotes().find((note) => note.title === 'B') + await this.application.mutator.changeItem(pinnedNote, (mutator) => { + mutator.pinned = true + }) + const tag = await this.application.mutator.findOrCreateTag('A') + await this.application.mutator.changeItem(tag, (mutator) => { + for (const note of this.application.itemManager.getDisplayableNotes()) { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + } + }) + + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + tags: [tag], + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.have.length(4) + /** setPrimaryItemDisplayOptions inverses sort for title */ + expect(displayedNotes[0].title).to.equal('B') + expect(displayedNotes[1].title).to.equal('A') + }) + }) + + describe('Smart views', function () { + it('"title", "startsWith", "Foo"', async function () { + const note = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'Foo 🎲', + }), + ) + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'Not Foo 🎲', + }), + ) + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Foo Notes', + predicate: { + keypath: 'title', + operator: 'startsWith', + value: 'Foo', + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(note.uuid) + }) + + it('"pinned", "=", true', async function () { + const note = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(note, (mutator) => { + mutator.pinned = true + }) + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'B', + pinned: false, + }), + ) + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Pinned', + predicate: { + keypath: 'pinned', + operator: '=', + value: true, + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(note.uuid) + }) + + it('"pinned", "=", false', async function () { + const pinnedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(pinnedNote, (mutator) => { + mutator.pinned = true + }) + const unpinnedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'B', + }), + ) + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Not pinned', + predicate: { + keypath: 'pinned', + operator: '=', + value: false, + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(unpinnedNote.uuid) + }) + + it('"text.length", ">", 500', async function () { + const longNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + text: Array(501).fill(0).join(''), + }), + ) + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'B', + text: 'b', + }), + ) + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Long', + predicate: { + keypath: 'text.length', + operator: '>', + value: 500, + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(longNote.uuid) + }) + + it('"updated_at", ">", "1.days.ago"', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: Utils.generateUuid(), + password: Utils.generateUuid(), + }) + + const recentNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + + await this.application.sync.sync() + + const olderNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'B', + text: 'b', + }), + ) + + const threeDays = 3 * 24 * 60 * 60 * 1000 + await Factory.changePayloadUpdatedAt(this.application, olderNote.payload, new Date(Date.now() - threeDays)) + + /** Create an unsynced note which shouldn't get an updated_at */ + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'B', + text: 'b', + }), + ) + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'One day ago', + predicate: { + keypath: 'serverUpdatedAt', + operator: '>', + value: '1.days.ago', + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(recentNote.uuid) + }) + + it('"tags.length", "=", 0', async function () { + const untaggedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + const taggedNote = await Factory.createMappedNote(this.application) + const tag = await this.application.mutator.findOrCreateTag('A') + await this.application.mutator.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) + }) + + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Untagged', + predicate: { + keypath: 'tags.length', + operator: '=', + value: 0, + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(untaggedNote.uuid) + }) + + it('"tags", "includes", ["title", "startsWith", "b"]', async function () { + const taggedNote = await Factory.createMappedNote(this.application) + const tag = await this.application.mutator.findOrCreateTag('B') + await this.application.mutator.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote) + }) + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'B-tags', + predicate: { + keypath: 'tags', + operator: 'includes', + value: { keypath: 'title', operator: 'startsWith', value: 'B' }, + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(taggedNote.uuid) + }) + + it('"ignored", "and", [["pinned", "=", true], ["locked", "=", true]]', async function () { + const pinnedAndLockedNote = await Factory.createMappedNote(this.application) + await this.application.mutator.changeItem(pinnedAndLockedNote, (mutator) => { + mutator.pinned = true + mutator.locked = true + }) + + const pinnedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(pinnedNote, (mutator) => { + mutator.pinned = true + }) + + const lockedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(lockedNote, (mutator) => { + mutator.locked = true + }) + + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Pinned & Locked', + predicate: { + operator: 'and', + value: [ + { keypath: 'pinned', operator: '=', value: true }, + { keypath: 'locked', operator: '=', value: true }, + ], + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes).to.deep.equal(matches) + expect(matches.length).to.equal(1) + expect(matches[0].uuid).to.equal(pinnedAndLockedNote.uuid) + }) + + it('"ignored", "or", [["content.protected", "=", true], ["pinned", "=", true]]', async function () { + const protectedNote = await Factory.createMappedNote(this.application) + await this.application.mutator.changeItem(protectedNote, (mutator) => { + mutator.protected = true + }) + + const pinnedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(pinnedNote, (mutator) => { + mutator.pinned = true + }) + + const pinnedAndProtectedNote = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + await this.application.mutator.changeItem(pinnedAndProtectedNote, (mutator) => { + mutator.pinned = true + mutator.protected = true + }) + + await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.Note, { + title: 'A', + }), + ) + + const view = await this.application.mutator.insertItem( + await this.application.mutator.createTemplateItem(ContentType.SmartView, { + title: 'Protected or Pinned', + predicate: { + operator: 'or', + value: [ + { keypath: 'content.protected', operator: '=', value: true }, + { keypath: 'pinned', operator: '=', value: true }, + ], + }, + }), + ) + const matches = this.application.items.notesMatchingSmartView(view) + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'created_at', + sortDirection: 'asc', + views: [view], + }) + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes.length).to.equal(matches.length) + expect(matches.length).to.equal(3) + expect(matches.find((note) => note.uuid === protectedNote.uuid)).to.exist + expect(matches.find((note) => note.uuid === pinnedNote.uuid)).to.exist + expect(matches.find((note) => note.uuid === pinnedAndProtectedNote.uuid)).to.exist + }) + }) + + it('include notes that have tag titles that match search query', async function () { + const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload({ + noteTitle: 'A simple note', + noteText: 'This is just a note.', + tagTitle: 'Test', + }) + const notePayload2 = Factory.createNotePayload('Foo') + const notePayload3 = Factory.createNotePayload('Bar') + const notePayload4 = Factory.createNotePayload('Testing') + + await this.application.itemManager.emitItemsFromPayloads( + [notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1], + PayloadEmitSource.LocalChanged, + ) + + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + searchQuery: { + query: 'Test', + }, + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes.length).to.equal(2) + /** setPrimaryItemDisplayOptions inverses sort for title */ + expect(displayedNotes[0].uuid).to.equal(notePayload1.uuid) + expect(displayedNotes[1].uuid).to.equal(notePayload4.uuid) + }) + + it('search query should be case insensitive and match notes and tags title', async function () { + const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload({ + noteTitle: 'A simple note', + noteText: 'Just a note. Nothing to see.', + tagTitle: 'Foo', + }) + const notePayload2 = Factory.createNotePayload('Another bar (foo)') + const notePayload3 = Factory.createNotePayload('Testing FOO (Bar)') + const notePayload4 = Factory.createNotePayload('This should not match') + + await this.application.itemManager.emitItemsFromPayloads( + [notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1], + PayloadEmitSource.LocalChanged, + ) + + this.application.items.setPrimaryItemDisplayOptions({ + sortBy: 'title', + sortDirection: 'dsc', + searchQuery: { + query: 'foo', + }, + }) + + const displayedNotes = this.application.items.getDisplayableNotes() + expect(displayedNotes.length).to.equal(3) + /** setPrimaryItemDisplayOptions inverses sort for title */ + expect(displayedNotes[0].uuid).to.equal(notePayload1.uuid) + expect(displayedNotes[1].uuid).to.equal(notePayload2.uuid) + expect(displayedNotes[2].uuid).to.equal(notePayload3.uuid) + }) +}) diff --git a/packages/snjs/mocha/model_tests/notes_tags_folders.test.js b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js new file mode 100644 index 000000000..c450119e1 --- /dev/null +++ b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js @@ -0,0 +1,86 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' + +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('tags as folders', () => { + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('lets me create a tag, add relationships, move a note to a children, and query data all along', async function () { + // ## The user creates four tags + let tagChildren = await Factory.createMappedTag(this.application, { + title: 'children', + }) + let tagParent = await Factory.createMappedTag(this.application, { + title: 'parent', + }) + let tagGrandParent = await Factory.createMappedTag(this.application, { + title: 'grandparent', + }) + let tagGrandParent2 = await Factory.createMappedTag(this.application, { + title: 'grandparent2', + }) + + // ## Now the users moves the tag children into the parent + await this.application.mutator.setTagParent(tagParent, tagChildren) + + expect(this.application.items.getTagParent(tagChildren)).to.equal(tagParent) + expect(Uuids(this.application.items.getTagChildren(tagParent))).deep.to.equal(Uuids([tagChildren])) + + // ## Now the user moves the tag parent into the grand parent + await this.application.mutator.setTagParent(tagGrandParent, tagParent) + + expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent) + expect(Uuids(this.application.items.getTagChildren(tagGrandParent))).deep.to.equal(Uuids([tagParent])) + + // ## Now the user moves the tag parent into another grand parent + await this.application.mutator.setTagParent(tagGrandParent2, tagParent) + + expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent2) + expect(this.application.items.getTagChildren(tagGrandParent)).deep.to.equal([]) + expect(Uuids(this.application.items.getTagChildren(tagGrandParent2))).deep.to.equal(Uuids([tagParent])) + + // ## Now the user tries to move the tag into one of its children + await expect(this.application.mutator.setTagParent(tagChildren, tagParent)).to.eventually.be.rejected + + expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent2) + expect(this.application.items.getTagChildren(tagGrandParent)).deep.to.equal([]) + expect(Uuids(this.application.items.getTagChildren(tagGrandParent2))).deep.to.equal(Uuids([tagParent])) + + // ## Now the user move the tag outside any hierarchy + await this.application.mutator.unsetTagParent(tagParent) + + expect(this.application.items.getTagParent(tagParent)).to.equal(undefined) + expect(this.application.items.getTagChildren(tagGrandParent2)).deep.to.equals([]) + }) + + it('lets me add a note to a tag hierarchy', async function () { + // ## The user creates four tags hierarchy + const tags = await Factory.createTags(this.application, { + grandparent: { parent: { child: true } }, + another: true, + }) + + const note1 = await Factory.createMappedNote(this.application, 'my first note') + const note2 = await Factory.createMappedNote(this.application, 'my second note') + + // ## The user add a note to the child tag + await this.application.items.addTagToNote(note1, tags.child, true) + await this.application.items.addTagToNote(note2, tags.another, true) + + // ## The note has been added to other tags + const note1Tags = await this.application.items.getSortedTagsForNote(note1) + const note2Tags = await this.application.items.getSortedTagsForNote(note2) + + expect(note1Tags.length).to.equal(3) + expect(note2Tags.length).to.equal(1) + }) +}) diff --git a/packages/snjs/mocha/model_tests/performance.test.js b/packages/snjs/mocha/model_tests/performance.test.js new file mode 100644 index 000000000..233cf56e5 --- /dev/null +++ b/packages/snjs/mocha/model_tests/performance.test.js @@ -0,0 +1,140 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('mapping performance', () => { + it('shouldnt take a long time', async () => { + /* + There was an issue with mapping where we were using arrays for everything instead of hashes (like items, missedReferences), + which caused searching to be really expensive and caused a huge slowdown. + */ + const application = await Factory.createInitAppWithFakeCrypto() + + // create a bunch of notes and tags, and make sure mapping doesn't take a long time + const noteCount = 1500 + const tagCount = 10 + const tags = [] + const notes = [] + for (let i = 0; i < tagCount; i++) { + var tag = { + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.Tag, + content: { + title: `${Math.random()}`, + references: [], + }, + } + tags.push(tag) + } + for (let i = 0; i < noteCount; i++) { + const note = { + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.Note, + content: { + title: `${Math.random()}`, + text: `${Math.random()}`, + references: [], + }, + } + const randomTag = Factory.randomArrayValue(tags) + randomTag.content.references.push({ + content_type: ContentType.Note, + uuid: note.uuid, + }) + notes.push(note) + } + + const payloads = Factory.shuffleArray(tags.concat(notes)).map((item) => { + return new DecryptedPayload(item) + }) + + const t0 = performance.now() + // process items in separate batches, so as to trigger missed references + let currentIndex = 0 + const batchSize = 100 + for (let i = 0; i < payloads.length; i += batchSize) { + const subArray = payloads.slice(currentIndex, currentIndex + batchSize) + await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) + currentIndex += batchSize + } + + const t1 = performance.now() + const seconds = (t1 - t0) / 1000 + const expectedRunTime = 3 // seconds + expect(seconds).to.be.at.most(expectedRunTime) + + for (const note of application.itemManager.getItems(ContentType.Note)) { + expect(application.itemManager.itemsReferencingItem(note).length).to.be.above(0) + } + await Factory.safeDeinit(application) + }).timeout(20000) + + it('mapping a tag with thousands of notes should be quick', async () => { + /* + There was an issue where if you have a tag with thousands of notes, it will take minutes to resolve. + Fixed now. The issue was that we were looping around too much. I've consolidated some of the loops + so that things require less loops in payloadManager, regarding missedReferences. + */ + const application = await Factory.createInitAppWithFakeCrypto() + + const noteCount = 10000 + const notes = [] + + const tag = { + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.Tag, + content: { + title: `${Math.random()}`, + references: [], + }, + } + + for (let i = 0; i < noteCount; i++) { + const note = { + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.Note, + content: { + title: `${Math.random()}`, + text: `${Math.random()}`, + references: [], + }, + } + + tag.content.references.push({ + content_type: ContentType.Note, + uuid: note.uuid, + }) + notes.push(note) + } + + const payloads = [tag].concat(notes).map((item) => new DecryptedPayload(item)) + + const t0 = performance.now() + // process items in separate batches, so as to trigger missed references + let currentIndex = 0 + const batchSize = 100 + for (let i = 0; i < payloads.length; i += batchSize) { + var subArray = payloads.slice(currentIndex, currentIndex + batchSize) + await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged) + currentIndex += batchSize + } + + const t1 = performance.now() + const seconds = (t1 - t0) / 1000 + /** Expected run time depends on many different factors, + * like how many other tests you're running and overall system capacity. + * Locally, best case should be around 3.3s and worst case should be 5s. + * However on CI this can sometimes take up to 10s. + */ + const MAX_RUN_TIME = 15.0 // seconds + expect(seconds).to.be.at.most(MAX_RUN_TIME) + + application.itemManager.getItems(ContentType.Tag)[0] + for (const note of application.itemManager.getItems(ContentType.Note)) { + expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1) + } + await Factory.safeDeinit(application) + }).timeout(20000) +}) diff --git a/packages/snjs/mocha/mutator.test.js b/packages/snjs/mocha/mutator.test.js new file mode 100644 index 000000000..88b8aeee9 --- /dev/null +++ b/packages/snjs/mocha/mutator.test.js @@ -0,0 +1,154 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('mutator', () => { + beforeEach(async function () { + this.createBarePayload = () => { + return new DecryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: { + title: 'hello', + }, + }) + } + + this.createNote = () => { + return new DecryptedItem(this.createBarePayload()) + } + + this.createTag = (notes = []) => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return new SNTag( + new DecryptedPayload({ + uuid: Factory.generateUuidish(), + content_type: ContentType.Tag, + content: { + title: 'thoughts', + references: references, + }, + }), + ) + } + }) + + it('mutate set domain data key', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.setDomainDataKey('somekey', 'somevalue', 'somedomain') + const payload = mutator.getResult() + + expect(payload.content.appData.somedomain.somekey).to.equal('somevalue') + }) + + it('mutate set pinned', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.pinned = true + const payload = mutator.getResult() + + expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].pinned).to.equal(true) + }) + + it('mutate set archived', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.archived = true + const payload = mutator.getResult() + + expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].archived).to.equal(true) + }) + + it('mutate set locked', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.locked = true + const payload = mutator.getResult() + + expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].locked).to.equal(true) + }) + + it('mutate set protected', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.protected = true + const payload = mutator.getResult() + + expect(payload.content.protected).to.equal(true) + }) + + it('mutate set trashed', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + mutator.trashed = true + const payload = mutator.getResult() + + expect(payload.content.trashed).to.equal(true) + }) + + it('calling get result should set us dirty', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + const payload = mutator.getResult() + + expect(payload.dirty).to.equal(true) + }) + + it('get result should always have userModifiedDate', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item) + const payload = mutator.getResult() + const resultItem = CreateDecryptedItemFromPayload(payload) + expect(resultItem.userModifiedDate).to.be.ok + }) + + it('mutate set deleted', function () { + const item = this.createNote() + const mutator = new DeleteItemMutator(item) + const payload = mutator.getDeletedResult() + + expect(payload.content).to.not.be.ok + expect(payload.deleted).to.equal(true) + expect(payload.dirty).to.equal(true) + }) + + it('mutate app data', function () { + const item = this.createNote() + const mutator = new DecryptedItemMutator(item, MutationType.UpdateUserTimestamps) + mutator.setAppDataItem('foo', 'bar') + mutator.setAppDataItem('bar', 'foo') + const payload = mutator.getResult() + expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].foo).to.equal('bar') + expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].bar).to.equal('foo') + }) + + it('mutate add item as relationship', function () { + const note = this.createNote() + const tag = this.createTag() + const mutator = new DecryptedItemMutator(tag) + mutator.e2ePendingRefactor_addItemAsRelationship(note) + const payload = mutator.getResult() + + const item = new DecryptedItem(payload) + expect(item.isReferencingItem(note)).to.equal(true) + }) + + it('mutate remove item as relationship', function () { + const note = this.createNote() + const tag = this.createTag([note]) + const mutator = new DecryptedItemMutator(tag) + mutator.removeItemAsRelationship(note) + const payload = mutator.getResult() + + const item = new DecryptedItem(payload) + expect(item.isReferencingItem(note)).to.equal(false) + }) +}) diff --git a/packages/snjs/mocha/note_display_criteria.test.js b/packages/snjs/mocha/note_display_criteria.test.js new file mode 100644 index 000000000..750bef622 --- /dev/null +++ b/packages/snjs/mocha/note_display_criteria.test.js @@ -0,0 +1,706 @@ +/* eslint-disable no-undef */ +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('note display criteria', function () { + beforeEach(async function () { + this.payloadManager = new PayloadManager() + this.itemManager = new ItemManager(this.payloadManager) + + this.createNote = async (title = 'hello', text = 'world') => { + return this.itemManager.createItem(ContentType.Note, { + title: title, + text: text, + }) + } + + this.createTag = async (notes = [], title = 'thoughts') => { + const references = notes.map((note) => { + return { + uuid: note.uuid, + content_type: note.content_type, + } + }) + return this.itemManager.createItem(ContentType.Tag, { + title: title, + references: references, + }) + } + }) + + it('includePinned off', async function () { + await this.createNote() + const pendingPin = await this.createNote() + await this.itemManager.changeItem(pendingPin, (mutator) => { + mutator.pinned = true + }) + const criteria = { + includePinned: false, + } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(1) + }) + + it('includePinned on', async function () { + await this.createNote() + const pendingPin = await this.createNote() + await this.itemManager.changeItem(pendingPin, (mutator) => { + mutator.pinned = true + }) + const criteria = { includePinned: true } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(2) + }) + + it('includeTrashed off', async function () { + await this.createNote() + const pendingTrash = await this.createNote() + await this.itemManager.changeItem(pendingTrash, (mutator) => { + mutator.trashed = true + }) + const criteria = { includeTrashed: false } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(1) + }) + + it('includeTrashed on', async function () { + await this.createNote() + const pendingTrash = await this.createNote() + await this.itemManager.changeItem(pendingTrash, (mutator) => { + mutator.trashed = true + }) + const criteria = { includeTrashed: true } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(2) + }) + + it('includeArchived off', async function () { + await this.createNote() + const pendingArchive = await this.createNote() + await this.itemManager.changeItem(pendingArchive, (mutator) => { + mutator.archived = true + }) + const criteria = { includeArchived: false } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(1) + }) + + it('includeArchived on', async function () { + await this.createNote() + const pendingArchive = await this.createNote() + await this.itemManager.changeItem(pendingArchive, (mutator) => { + mutator.archived = true + }) + const criteria = { + includeArchived: true, + } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(2) + }) + + it('includeProtected off', async function () { + await this.createNote() + const pendingProtected = await this.createNote() + await this.itemManager.changeItem(pendingProtected, (mutator) => { + mutator.protected = true + }) + const criteria = { includeProtected: false } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(1) + }) + + it('includeProtected on', async function () { + await this.createNote() + const pendingProtected = await this.createNote() + await this.itemManager.changeItem(pendingProtected, (mutator) => { + mutator.protected = true + }) + const criteria = { + includeProtected: true, + } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(2) + }) + + it('protectedSearchEnabled false', async function () { + const normal = await this.createNote('hello', 'world') + await this.itemManager.changeItem(normal, (mutator) => { + mutator.protected = true + }) + const criteria = { + searchQuery: { query: 'world', includeProtectedNoteText: false }, + } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(0) + }) + + it('protectedSearchEnabled true', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.protected = true + }) + const criteria = { + searchQuery: { query: 'world', includeProtectedNoteText: true }, + } + expect( + itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection) + .length, + ).to.equal(1) + }) + + it('tags', async function () { + const note = await this.createNote() + const tag = await this.createTag([note]) + const looseTag = await this.createTag([], 'loose') + + const matchingCriteria = { + tags: [tag], + } + expect( + itemsMatchingOptions( + matchingCriteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + const nonmatchingCriteria = { + tags: [looseTag], + } + expect( + itemsMatchingOptions( + nonmatchingCriteria, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + describe('smart views', function () { + it('normal note', async function () { + await this.createNote() + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('trashed note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeTrashed: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('archived note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = false + mutator.archived = true + }) + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + + it('archived + trashed note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + }) + + describe('includeTrash', function () { + it('normal note', async function () { + await this.createNote() + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeTrashed: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeTrashed: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('trashed note', async function () { + const normal = await this.createNote() + + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeTrashed: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeTrashed: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeTrashed: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + includeTrashed: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('archived + trashed note', async function () { + const normal = await this.createNote() + + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + }) + + describe('includeArchived', function () { + it('normal note', async function () { + await this.createNote() + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('archived note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + includeArchived: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + + it('archived + trashed note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + }) + + describe.skip('multiple tags', function () { + it('normal note', async function () { + await this.createNote() + + expect( + itemsMatchingOptions( + { + views: [ + this.itemManager.allNotesSmartView, + this.itemManager.archivedSmartView, + this.itemManager.trashSmartView, + ], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + + it('archived note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + includeArchived: false, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + }) + + it('archived + trashed note', async function () { + const normal = await this.createNote() + await this.itemManager.changeItem(normal, (mutator) => { + mutator.trashed = true + mutator.archived = true + }) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.allNotesSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.trashSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(1) + + expect( + itemsMatchingOptions( + { + views: [this.itemManager.archivedSmartView], + includeArchived: true, + }, + this.itemManager.collection.all(ContentType.Note), + this.itemManager.collection, + ).length, + ).to.equal(0) + }) + }) +}) diff --git a/packages/snjs/mocha/payload.test.js b/packages/snjs/mocha/payload.test.js new file mode 100644 index 000000000..ec03b2d71 --- /dev/null +++ b/packages/snjs/mocha/payload.test.js @@ -0,0 +1,137 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +chai.use(chaiAsPromised) +const expect = chai.expect +import * as Factory from './lib/factory.js' + +describe('payload', () => { + beforeEach(async function () { + this.createBarePayload = () => { + return new DecryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: { + title: 'hello', + }, + }) + } + + this.createEncryptedPayload = () => { + return new EncryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: '004:foo:bar', + }) + } + }) + + it('constructor should set expected fields', function () { + const payload = this.createBarePayload() + + expect(payload.uuid).to.be.ok + expect(payload.content_type).to.be.ok + expect(payload.content).to.be.ok + }) + + it('not supplying source should default to constructor source', function () { + const payload = new DecryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: { + title: 'hello', + }, + }) + + expect(payload.source).to.equal(PayloadSource.Constructor) + }) + + it('created at should default to present', function () { + const payload = this.createBarePayload() + + expect(payload.created_at - new Date()).to.be.below(1) + }) + + it('updated at should default to epoch', function () { + const payload = this.createBarePayload() + + expect(payload.updated_at.getTime()).to.equal(0) + }) + + it('payload format bare', function () { + const payload = this.createBarePayload() + + expect(isDecryptedPayload(payload)).to.equal(true) + }) + + it('payload format encrypted string', function () { + const payload = this.createEncryptedPayload() + + expect(isEncryptedPayload(payload)).to.equal(true) + }) + + it('payload with unrecognized prefix should be corrupt', async function () { + await Factory.expectThrowsAsync( + () => + new EncryptedPayload({ + uuid: '123', + content_type: ContentType.Note, + content: '000:somebase64string', + }), + 'Unrecognized protocol version 000', + ) + }) + + it('payload format deleted', function () { + const payload = new DeletedPayload({ + uuid: '123', + content_type: ContentType.Note, + deleted: true, + }) + + expect(isDeletedPayload(payload)).to.equal(true) + }) + + it('payload version 004', function () { + const payload = this.createEncryptedPayload() + + expect(payload.version).to.equal('004') + }) + + it('merged with absent content', function () { + const payload = this.createBarePayload() + const merged = payload.copy({ + uuid: '123', + content_type: ContentType.Note, + updated_at: new Date(), + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + }) + + expect(merged.content).to.eql(payload.content) + expect(merged.uuid).to.equal(payload.uuid) + expect(merged.dirty).to.equal(true) + expect(merged.updated_at.getTime()).to.be.above(1) + }) + + it('deleted and not dirty should be discardable', function () { + const payload = new DeletedPayload({ + uuid: '123', + content_type: ContentType.Note, + deleted: true, + dirty: false, + }) + + expect(payload.discardable).to.equal(true) + }) + + it('should be immutable', async function () { + const payload = this.createBarePayload() + + await Factory.sleep(0.1) + + const changeFn = () => { + payload.foo = 'bar' + } + expect(changeFn).to.throw() + }) +}) diff --git a/packages/snjs/mocha/payload_encryption.test.js b/packages/snjs/mocha/payload_encryption.test.js new file mode 100644 index 000000000..ad1ae81ce --- /dev/null +++ b/packages/snjs/mocha/payload_encryption.test.js @@ -0,0 +1,194 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import { createRelatedNoteTagPairPayload } from './lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('payload encryption', function () { + beforeEach(async function () { + this.timeout(Factory.TenSecondTimeout) + localStorage.clear() + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + it('creating payload from item should create copy not by reference', async function () { + const item = await Factory.createMappedNote(this.application) + const payload = new DecryptedPayload(item.payload.ejected()) + expect(item.content === payload.content).to.equal(false) + expect(item.content.references === payload.content.references).to.equal(false) + }) + + it('creating payload from item should preserve appData', async function () { + const item = await Factory.createMappedNote(this.application) + const payload = new DecryptedPayload(item.payload.ejected()) + expect(item.content.appData).to.be.ok + expect(JSON.stringify(item.content)).to.equal(JSON.stringify(payload.content)) + }) + + it('server payloads should not contain client values', async function () { + const rawPayload = Factory.createNotePayload() + const notePayload = new DecryptedPayload({ + ...rawPayload, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + lastSyncBegan: new Date(), + }) + + const encryptedPayload = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [notePayload], + }, + }) + + const syncPayload = CreateEncryptedServerSyncPushPayload(encryptedPayload) + + expect(syncPayload.dirty).to.not.be.ok + expect(syncPayload.errorDecrypting).to.not.be.ok + expect(syncPayload.waitingForKey).to.not.be.ok + expect(syncPayload.lastSyncBegan).to.not.be.ok + }) + + it('creating payload with override properties', function () { + const payload = Factory.createNotePayload() + const uuid = payload.uuid + const changedUuid = 'foo' + const changedPayload = new DecryptedPayload({ + ...payload, + uuid: changedUuid, + }) + + expect(payload.uuid).to.equal(uuid) + expect(changedPayload.uuid).to.equal(changedUuid) + }) + + it('creating payload with deep override properties', function () { + const payload = Factory.createNotePayload() + const text = payload.content.text + const changedText = `${Math.random()}` + const changedPayload = new DecryptedPayload({ + ...payload, + content: { + ...payload.content, + text: changedText, + }, + }) + + expect(payload.content === changedPayload.content).to.equal(false) + expect(payload.content.text).to.equal(text) + expect(changedPayload.content.text).to.equal(changedText) + }) + + it('copying payload with override content should override completely', async function () { + const item = await Factory.createMappedNote(this.application) + const payload = new DecryptedPayload(item.payload.ejected()) + const mutated = new DecryptedPayload({ + ...payload, + content: { + foo: 'bar', + }, + }) + expect(mutated.content.text).to.not.be.ok + }) + + it('copying payload with override should copy empty arrays', function () { + const pair = createRelatedNoteTagPairPayload() + const tagPayload = pair[1] + expect(tagPayload.content.references.length).to.equal(1) + + const mutated = new DecryptedPayload({ + ...tagPayload, + content: { + ...tagPayload.content, + references: [], + }, + }) + expect(mutated.content.references.length).to.equal(0) + }) + + it('returns valid encrypted params for syncing', async function () { + const payload = Factory.createNotePayload() + const encryptedPayload = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload.enc_item_key).to.be.ok + expect(encryptedPayload.uuid).to.be.ok + expect(encryptedPayload.auth_hash).to.not.be.ok + expect(encryptedPayload.content_type).to.be.ok + expect(encryptedPayload.created_at).to.be.ok + expect(encryptedPayload.content).to.satisfy((string) => { + return string.startsWith(this.application.protocolService.getLatestVersion()) + }) + }).timeout(5000) + + it('returns additional fields for local storage', async function () { + const payload = Factory.createNotePayload() + + const encryptedPayload = CreateEncryptedLocalStorageContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + + expect(encryptedPayload.enc_item_key).to.be.ok + expect(encryptedPayload.auth_hash).to.not.be.ok + expect(encryptedPayload.uuid).to.be.ok + expect(encryptedPayload.content_type).to.be.ok + expect(encryptedPayload.created_at).to.be.ok + expect(encryptedPayload.updated_at).to.be.ok + expect(encryptedPayload.deleted).to.not.be.ok + expect(encryptedPayload.errorDecrypting).to.not.be.ok + expect(encryptedPayload.content).to.satisfy((string) => { + return string.startsWith(this.application.protocolService.getLatestVersion()) + }) + }) + + it('omits deleted for export file', async function () { + const payload = Factory.createNotePayload() + const encryptedPayload = CreateEncryptedBackupFileContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + + expect(encryptedPayload.enc_item_key).to.be.ok + expect(encryptedPayload.uuid).to.be.ok + expect(encryptedPayload.content_type).to.be.ok + expect(encryptedPayload.created_at).to.be.ok + expect(encryptedPayload.deleted).to.not.be.ok + expect(encryptedPayload.content).to.satisfy((string) => { + return string.startsWith(this.application.protocolService.getLatestVersion()) + }) + }) + + it('items with error decrypting should remain as is', async function () { + const payload = Factory.createNotePayload() + const mutatedPayload = new EncryptedPayload({ + ...payload, + content: '004:...', + enc_item_key: 'foo', + errorDecrypting: true, + }) + + const syncPayload = CreateEncryptedServerSyncPushPayload(mutatedPayload) + + expect(syncPayload.content).to.eql(mutatedPayload.content) + expect(syncPayload.enc_item_key).to.be.ok + expect(syncPayload.uuid).to.be.ok + expect(syncPayload.content_type).to.be.ok + expect(syncPayload.created_at).to.be.ok + }) +}) diff --git a/packages/snjs/mocha/payload_manager.test.js b/packages/snjs/mocha/payload_manager.test.js new file mode 100644 index 000000000..5ce0e7573 --- /dev/null +++ b/packages/snjs/mocha/payload_manager.test.js @@ -0,0 +1,89 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('payload manager', () => { + beforeEach(async function () { + this.payloadManager = new PayloadManager() + this.createNotePayload = async () => { + return new DecryptedPayload({ + uuid: Factory.generateUuidish(), + content_type: ContentType.Note, + content: { + title: 'hello', + text: 'world', + }, + }) + } + }) + + it('emit payload should create local record', async function () { + const payload = await this.createNotePayload() + await this.payloadManager.emitPayload(payload) + + expect(this.payloadManager.collection.find(payload.uuid)).to.be.ok + }) + + it('merge payloads onto master', async function () { + const payload = await this.createNotePayload() + await this.payloadManager.emitPayload(payload) + + const newTitle = `${Math.random()}` + const changedPayload = payload.copy({ + content: { + ...payload.content, + title: newTitle, + }, + }) + const { changed, inserted } = await this.payloadManager.applyPayloads([changedPayload]) + expect(changed.length).to.equal(1) + expect(inserted.length).to.equal(0) + expect(this.payloadManager.collection.find(payload.uuid).content.title).to.equal(newTitle) + }) + + it('insertion observer', async function () { + const observations = [] + this.payloadManager.addObserver(ContentType.Any, ({ inserted }) => { + observations.push({ inserted }) + }) + const payload = await this.createNotePayload() + await this.payloadManager.emitPayload(payload) + + expect(observations.length).equal(1) + expect(observations[0].inserted[0]).equal(payload) + }) + + it('change observer', async function () { + const observations = [] + this.payloadManager.addObserver(ContentType.Any, ({ changed }) => { + if (changed.length > 0) { + observations.push({ changed }) + } + }) + const payload = await this.createNotePayload() + await this.payloadManager.emitPayload(payload) + await this.payloadManager.emitPayload( + payload.copy({ + content: { + ...payload.content, + title: 'new title', + }, + }), + ) + + expect(observations.length).equal(1) + expect(observations[0].changed[0].uuid).equal(payload.uuid) + }) + + it('reset state', async function () { + this.payloadManager.addObserver(ContentType.Any, ({}) => {}) + const payload = await this.createNotePayload() + await this.payloadManager.emitPayload(payload) + await this.payloadManager.resetState() + + expect(this.payloadManager.collection.all().length).to.equal(0) + expect(this.payloadManager.changeObservers.length).equal(1) + }) +}) diff --git a/packages/snjs/mocha/preferences.test.js b/packages/snjs/mocha/preferences.test.js new file mode 100644 index 000000000..e989b8685 --- /dev/null +++ b/packages/snjs/mocha/preferences.test.js @@ -0,0 +1,102 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('preferences', function () { + beforeEach(async function () { + localStorage.clear() + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + function register() { + return Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + } + + it('sets preference', async function () { + await this.application.setPreference('editorLeft', 300) + expect(this.application.getPreference('editorLeft')).to.equal(300) + }) + + it('saves preference', async function () { + await register.call(this) + await this.application.setPreference('editorLeft', 300) + await this.application.sync.sync() + this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password) + const editorLeft = this.application.getPreference('editorLeft') + expect(editorLeft).to.equal(300) + }).timeout(10000) + + it('clears preferences on signout', async function () { + await register.call(this) + await this.application.setPreference('editorLeft', 300) + await this.application.sync.sync() + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + expect(this.application.getPreference('editorLeft')).to.equal(undefined) + }) + + it('returns default value for non-existent preference', async function () { + await register.call(this) + const editorLeft = this.application.getPreference('editorLeft', 100) + expect(editorLeft).to.equal(100) + }) + + it('emits an event when preferences change', async function () { + let callTimes = 0 + this.application.addEventObserver(() => { + callTimes++ + }, ApplicationEvent.PreferencesChanged) + callTimes += 1 + await Factory.sleep(0) /** Await next tick */ + expect(callTimes).to.equal(1) /** App start */ + await register.call(this) + await this.application.setPreference('editorLeft', 300) + expect(callTimes).to.equal(2) + }) + + it('discards existing preferences when signing in', async function () { + await register.call(this) + await this.application.setPreference('editorLeft', 300) + await this.application.sync.sync() + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.setPreference('editorLeft', 200) + await this.application.signIn(this.email, this.password) + await this.application.sync.sync({ awaitAll: true }) + const editorLeft = this.application.getPreference('editorLeft') + expect(editorLeft).to.equal(300) + }) + + it('reads stored preferences on start without waiting for syncing to complete', async function () { + const prefKey = 'editorLeft' + const prefValue = 300 + const identifier = this.application.identifier + + await register.call(this) + await this.application.setPreference(prefKey, prefValue) + await this.application.sync.sync() + + await Factory.safeDeinit(this.application) + + this.application = Factory.createApplicationWithFakeCrypto(identifier) + const willSyncPromise = new Promise((resolve) => { + this.application.addEventObserver(resolve, ApplicationEvent.WillSync) + }) + Factory.initializeApplication(this.application) + await willSyncPromise + + expect(this.application.preferencesService.preferences).to.exist + expect(this.application.getPreference(prefKey)).to.equal(prefValue) + }) +}) diff --git a/packages/snjs/mocha/protection.test.js b/packages/snjs/mocha/protection.test.js new file mode 100644 index 000000000..e0c393a72 --- /dev/null +++ b/packages/snjs/mocha/protection.test.js @@ -0,0 +1,624 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('protections', function () { + this.timeout(Factory.TenSecondTimeout) + + let application + + beforeEach(function () { + localStorage.clear() + }) + + afterEach(async function () { + await Factory.safeDeinit(application) + localStorage.clear() + }) + + it('prompts for password when accessing protected note', async function () { + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const password = UuidGenerator.GenerateUuid() + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.AccountPassword)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.AccountPassword + ? password + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password, + }) + + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + + expect(await application.authorizeNoteAccess(note)).to.be.true + expect(challengePrompts).to.equal(1) + }) + + it('sets `note.protected` to true', async function () { + application = await Factory.createInitAppWithFakeCrypto() + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + expect(note.protected).to.be.true + }) + + it('prompts for passcode when accessing protected note', async function () { + const passcode = 'passcode' + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + + expect(await application.authorizeNoteAccess(note)).to.be.true + expect(challengePrompts).to.equal(1) + }) + + it('prompts for passcode when unprotecting a note', async function () { + const passcode = 'passcode' + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + let note = await Factory.createMappedNote(application) + const uuid = note.uuid + note = await application.mutator.protectNote(note) + note = await application.mutator.unprotectNote(note) + expect(note.uuid).to.equal(uuid) + expect(note.protected).to.equal(false) + expect(challengePrompts).to.equal(1) + }) + + it('does not unprotect note if challenge is canceled', async function () { + const passcode = 'passcode' + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts++ + application.cancelChallenge(challenge) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + const result = await application.mutator.unprotectNote(note) + expect(result).to.be.undefined + expect(challengePrompts).to.equal(1) + }) + + it('does not prompt for passcode again after setting a remember duration', async function () { + const passcode = 'passcode' + + let challengePrompts = 0 + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneHour, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + + expect(await application.authorizeNoteAccess(note)).to.be.true + expect(await application.authorizeNoteAccess(note)).to.be.true + expect(challengePrompts).to.equal(1) + }) + + it('prompts for password when adding a passcode', async function () { + application = Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const password = UuidGenerator.GenerateUuid() + const passcode = 'passcode' + let didPromptForPassword = false + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + let values + if (challenge.prompts[0].validation === ChallengeValidation.AccountPassword) { + if (challenge.reason === ChallengeReason.AddPasscode) { + didPromptForPassword = true + } + values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.AccountPassword + ? password + : UnprotectedAccessSecondsDuration.OneHour, + ), + ) + } else { + values = [CreateChallengeValue(challenge.prompts[0], passcode)] + } + + application.submitValuesForChallenge(challenge, values) + }, + }) + + await application.launch(true) + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password, + }) + + await application.addPasscode(passcode) + expect(didPromptForPassword).to.equal(true) + }) + + it('authorizes note access when no password or passcode are set', async function () { + application = await Factory.createInitAppWithFakeCrypto() + + let note = await Factory.createMappedNote(application) + note = await application.mutator.protectNote(note) + + expect(await application.authorizeNoteAccess(note)).to.be.true + }) + + it('authorizes autolock interval change', async function () { + const passcode = 'passcode' + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + + expect(await application.authorizeAutolockIntervalChange()).to.be.true + }) + + it('authorizes batch manager access', async function () { + const passcode = 'passcode' + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + + await application.addPasscode(passcode) + + expect(await application.authorizeAutolockIntervalChange()).to.be.true + }) + + it('handles session length', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.protectionService.setSessionLength(300) + const length = await application.protectionService.getLastSessionLength() + expect(length).to.equal(300) + const expirey = await application.getProtectionSessionExpiryDate() + expect(expirey).to.be.ok + }) + + it('handles session length', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.protectionService.setSessionLength(UnprotectedAccessSecondsDuration.OneMinute) + const length = await application.protectionService.getLastSessionLength() + expect(length).to.equal(UnprotectedAccessSecondsDuration.OneMinute) + const expirey = await application.getProtectionSessionExpiryDate() + expect(expirey).to.be.ok + }) + + describe('hasProtectionSources', function () { + it('no account, no passcode, no biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + expect(application.hasProtectionSources()).to.be.false + }) + + it('no account, no passcode, biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.enableBiometrics() + expect(application.hasProtectionSources()).to.be.true + }) + + it('no account, passcode, no biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.addPasscode('passcode') + expect(application.hasProtectionSources()).to.be.true + }) + + it('no account, passcode, biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.addPasscode('passcode') + await application.enableBiometrics() + expect(application.hasProtectionSources()).to.be.true + }) + + it('account, no passcode, no biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password: UuidGenerator.GenerateUuid(), + }) + expect(application.hasProtectionSources()).to.be.true + }) + + it('account, no passcode, biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password: UuidGenerator.GenerateUuid(), + }) + await application.enableBiometrics() + expect(application.hasProtectionSources()).to.be.true + }) + + it('account, passcode, no biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + const password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password, + }) + Factory.handlePasswordChallenges(application, password) + await application.addPasscode('passcode') + expect(application.hasProtectionSources()).to.be.true + }) + + it('account, passcode, biometrics', async function () { + application = await Factory.createInitAppWithFakeCrypto() + const password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password, + }) + Factory.handlePasswordChallenges(application, password) + await application.addPasscode('passcode') + await application.enableBiometrics() + expect(application.hasProtectionSources()).to.be.true + }) + }) + + describe('hasUnprotectedAccessSession', function () { + it('should return false when session length has not been set', async function () { + this.foo = 'tar' + application = await Factory.createInitAppWithFakeCrypto() + await application.addPasscode('passcode') + expect(application.hasUnprotectedAccessSession()).to.be.false + }) + + it('should return true when session length has been set', async function () { + application = await Factory.createInitAppWithFakeCrypto() + await application.addPasscode('passcode') + await application.protectionService.setSessionLength(UnprotectedAccessSecondsDuration.OneMinute) + expect(application.hasUnprotectedAccessSession()).to.be.true + }) + + it('should return true when there are no protection sources', async function () { + application = await Factory.createInitAppWithFakeCrypto() + expect(application.hasUnprotectedAccessSession()).to.be.true + }) + }) + + describe('authorizeProtectedActionForNotes', function () { + it('prompts for password once with the right challenge reason when one or more notes are protected', async function () { + let challengePrompts = 0 + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const password = UuidGenerator.GenerateUuid() + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.AccountPassword)).to.be.ok + expect(challenge.reason).to.equal(ChallengeReason.SelectProtectedNote) + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.AccountPassword + ? password + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + await Factory.registerUserToApplication({ + application: application, + email: UuidGenerator.GenerateUuid(), + password, + }) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + + notes[0] = await application.mutator.protectNote(notes[0]) + notes[1] = await application.mutator.protectNote(notes[1]) + + expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf( + NOTE_COUNT, + ) + expect(challengePrompts).to.equal(1) + }) + + it('prompts for passcode once with the right challenge reason when one or more notes are protected', async function () { + let challengePrompts = 0 + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const passcode = 'passcode' + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + expect(challenge.reason).to.equal(ChallengeReason.SelectProtectedNote) + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + await application.addPasscode(passcode) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes[0] = await application.mutator.protectNote(notes[0]) + notes[1] = await application.mutator.protectNote(notes[1]) + + expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf( + NOTE_COUNT, + ) + expect(challengePrompts).to.equal(1) + }) + + it('does not return protected notes if challenge is canceled', async function () { + const passcode = 'passcode' + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts++ + application.cancelChallenge(challenge) + }, + }) + await application.launch(true) + await application.addPasscode(passcode) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes[0] = await application.mutator.protectNote(notes[0]) + notes[1] = await application.mutator.protectNote(notes[1]) + + expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf(1) + expect(challengePrompts).to.equal(1) + }) + }) + + describe('protectNotes', function () { + it('protects all notes', async function () { + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + application.cancelChallenge(challenge) + }, + }) + await application.launch(true) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes = await application.mutator.protectNotes(notes) + + for (const note of notes) { + expect(note.protected).to.be.true + } + }) + }) + + describe('unprotect notes', function () { + it('prompts for password and unprotects all notes if challenge is succesful', async function () { + let challengePrompts = 0 + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const passcode = 'passcode' + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + expect(challenge.reason).to.equal(ChallengeReason.UnprotectNote) + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + await application.addPasscode(passcode) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes = await application.mutator.protectNotes(notes) + notes = await application.mutator.unprotectNotes(notes) + + for (const note of notes) { + expect(note.protected).to.be.false + } + expect(challengePrompts).to.equal(1) + }) + + it('prompts for passcode and unprotects all notes if challenge is succesful', async function () { + let challengePrompts = 0 + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + const passcode = 'passcode' + + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts += 1 + expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok + expect(challenge.reason).to.equal(ChallengeReason.UnprotectNote) + const values = challenge.prompts.map( + (prompt) => + CreateChallengeValue( + prompt, + prompt.validation === ChallengeValidation.LocalPasscode + ? passcode + : UnprotectedAccessSecondsDuration.OneMinute, + ), + ) + + application.submitValuesForChallenge(challenge, values) + }, + }) + await application.launch(true) + await application.addPasscode(passcode) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes = await application.mutator.protectNotes(notes) + notes = await application.mutator.unprotectNotes(notes) + + for (const note of notes) { + expect(note.protected).to.be.false + } + expect(challengePrompts).to.equal(1) + }) + + it('does not unprotect any notes if challenge is canceled', async function () { + const passcode = 'passcode' + let challengePrompts = 0 + + application = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await application.prepareForLaunch({ + receiveChallenge: (challenge) => { + challengePrompts++ + application.cancelChallenge(challenge) + }, + }) + await application.launch(true) + await application.addPasscode(passcode) + + const NOTE_COUNT = 3 + let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT) + notes = await application.mutator.protectNotes(notes) + notes = await application.mutator.unprotectNotes(notes) + + for (const note of notes) { + expect(note.protected).to.be(true) + } + expect(challengePrompts).to.equal(1) + }) + }) +}) diff --git a/packages/snjs/mocha/protocol.test.js b/packages/snjs/mocha/protocol.test.js new file mode 100644 index 000000000..739bf2049 --- /dev/null +++ b/packages/snjs/mocha/protocol.test.js @@ -0,0 +1,198 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('protocol', function () { + beforeEach(async function () { + localStorage.clear() + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + this.application = null + localStorage.clear() + }) + + it('checks version to make sure its 004', function () { + expect(this.application.protocolService.getLatestVersion()).to.equal('004') + }) + + it('checks supported versions to make sure it includes 001, 002, 003, 004', function () { + expect(this.application.protocolService.supportedVersions()).to.eql(['001', '002', '003', '004']) + }) + + it('platform derivation support', function () { + expect( + this.application.protocolService.platformSupportsKeyDerivation({ + version: '001', + }), + ).to.equal(true) + expect( + this.application.protocolService.platformSupportsKeyDerivation({ + version: '002', + }), + ).to.equal(true) + expect( + this.application.protocolService.platformSupportsKeyDerivation({ + version: '003', + }), + ).to.equal(true) + expect( + this.application.protocolService.platformSupportsKeyDerivation({ + version: '004', + }), + ).to.equal(true) + expect( + this.application.protocolService.platformSupportsKeyDerivation({ + version: '005', + }), + ).to.equal(true) + }) + + it('key params versions <= 002 should include pw_cost in portable value', function () { + const keyParams002 = this.application.protocolService.createKeyParams({ + version: '002', + pw_cost: 5000, + }) + expect(keyParams002.getPortableValue().pw_cost).to.be.ok + }) + + it('version comparison of 002 should be older than library version', function () { + expect(this.application.protocolService.isVersionNewerThanLibraryVersion('002')).to.equal(false) + }) + + it('version comparison of 005 should be newer than library version', function () { + expect(this.application.protocolService.isVersionNewerThanLibraryVersion('005')).to.equal(true) + }) + + it('library version should not be outdated', function () { + var currentVersion = this.application.protocolService.getLatestVersion() + expect(isProtocolVersionExpired(currentVersion)).to.equal(false) + }) + + it('001 protocol should be expired', function () { + expect(isProtocolVersionExpired(ProtocolVersion.V001)).to.equal(true) + }) + + it('002 protocol should be expired', function () { + expect(isProtocolVersionExpired(ProtocolVersion.V002)).to.equal(true) + }) + + it('004 protocol should not be expired', function () { + expect(isProtocolVersionExpired(ProtocolVersion.V004)).to.equal(false) + }) + + it('decrypting already decrypted payload should throw', async function () { + const payload = Factory.createNotePayload() + let error + try { + await this.application.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }) + } catch (e) { + error = e + } + expect(error).to.be.ok + }) + + it('ejected payload should not have meta fields', async function () { + await this.application.addPasscode('123') + const payload = Factory.createNotePayload() + const result = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(result.fields).to.not.be.ok + expect(result.source).to.not.be.ok + expect(result.format).to.not.be.ok + expect(result.dirtyIndex).to.not.be.ok + }) + + it('encrypted payload for server should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) + + it('ejected payload for server should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) + + it('encrypted payload for storage should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedLocalStorageContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) + + it('ejected payload for storage should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedLocalStorageContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) + + it('encrypted payload for file should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedBackupFileContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) + + it('ejected payload for file should include duplicate_of field', async function () { + const payload = Factory.createNotePayload('Test') + const encryptedPayload = CreateEncryptedBackupFileContextPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [payload], + }, + }), + ) + expect(encryptedPayload).to.be.ok + expect(encryptedPayload).to.contain.keys('duplicate_of') + }) +}) diff --git a/packages/snjs/mocha/session-sharing.test.js b/packages/snjs/mocha/session-sharing.test.js new file mode 100644 index 000000000..65ef5fb6e --- /dev/null +++ b/packages/snjs/mocha/session-sharing.test.js @@ -0,0 +1,113 @@ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('session sharing', function () { + this.timeout(Factory.TenSecondTimeout) + + beforeEach(async function () { + localStorage.clear() + + this.context = await Factory.createAppContext() + await this.context.launch() + + this.application = this.context.application + this.email = this.context.email + this.password = this.context.password + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + }) + + afterEach(async function () { + await this.context.deinit() + this.context = undefined + this.application = undefined + localStorage.clear() + }) + + it('share token payloads should include neccessary params', async function () { + const token = await this.application.sessions.createDemoShareToken() + const payload = await this.application.sessions.decodeDemoShareToken(token) + + const expectedKeys = [ + 'accessToken', + 'refreshToken', + 'accessExpiration', + 'refreshExpiration', + 'readonlyAccess', + 'masterKey', + 'keyParams', + 'user', + 'host', + ] + + for (const key of expectedKeys) { + expect(payload[key]).to.not.be.undefined + } + }) + + it('populating session from share token should allow pulling in new items', async function () { + const token = await this.application.sessions.createDemoShareToken() + + await Factory.createSyncedNote(this.application, 'demo title', 'demo text') + + const otherContext = await Factory.createAppContext() + await otherContext.launch() + + const otherApplication = otherContext.application + + expect(otherApplication.items.getItems(ContentType.Note).length).to.equal(0) + + await otherApplication.sessions.populateSessionFromDemoShareToken(token) + + await otherApplication.sync.sync() + + const notes = otherApplication.items.getItems(ContentType.Note) + + expect(notes.length).to.equal(1) + + const note = notes[0] + + expect(note.title).to.equal('demo title') + expect(note.text).to.equal('demo text') + + await otherContext.deinit() + }) + + /** + * Demo session tokens can only be created manually via raw SQL entry on the DB side. + * There is no API to create share tokens. Therefore, the share token below is made from + * a copy of the master session, which is not readonly. + */ + it.skip('populating session from share token should not allow making changes', async function () { + const token = await this.application.sessions.createDemoShareToken() + + await Factory.createSyncedNote(this.application, 'demo title', 'demo text') + + const otherContext = await Factory.createAppContext() + await otherContext.launch() + + const otherApplication = otherContext.application + + await otherApplication.sessions.populateSessionFromDemoShareToken(token) + + await otherApplication.sync.sync() + + const note = otherApplication.items.getItems(ContentType.Note)[0] + + const syncResponse = otherContext.awaitNextSyncEvent(SyncEvent.SingleRoundTripSyncCompleted) + + await otherApplication.mutator.changeAndSaveItem(note, (mutator) => { + mutator.title = 'unauthorized change' + }) + + const result = await syncResponse + + expect(result.rawResponse.unsaved_items.length).to.equal(1) + }) +}) diff --git a/packages/snjs/mocha/session.test.js b/packages/snjs/mocha/session.test.js new file mode 100644 index 000000000..f3c3d3e85 --- /dev/null +++ b/packages/snjs/mocha/session.test.js @@ -0,0 +1,660 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import WebDeviceInterface from './lib/web_device_interface.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('server session', function () { + this.timeout(Factory.TenSecondTimeout) + + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + localStorage.clear() + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + this.newPassword = Factory.randomString() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + this.application = null + localStorage.clear() + }) + + async function sleepUntilSessionExpires(application, basedOnAccessToken = true) { + const currentSession = application.apiService.session + const timestamp = basedOnAccessToken ? currentSession.accessExpiration : currentSession.refreshExpiration + const timeRemaining = (timestamp - Date.now()) / 1000 // in ms + /* + If the token has not expired yet, we will return the remaining time. + Else, there's no need to add a delay. + */ + const sleepTime = timeRemaining > 0 ? timeRemaining + 1 /** Safety margin */ : 0 + await Factory.sleep(sleepTime) + } + + async function getSessionFromStorage(application) { + return application.diskStorageService.getValue(StorageKey.Session) + } + + it('should succeed when a sync request is perfomed with an expired access token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + await sleepUntilSessionExpires(this.application) + + const response = await this.application.apiService.sync([]) + + expect(response.status).to.equal(200) + }) + + it('should return the new session in the response when refreshed', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const response = await this.application.apiService.refreshSession() + + expect(response.status).to.equal(200) + expect(response.data.session.access_token).to.be.a('string') + expect(response.data.session.access_token).to.not.be.empty + expect(response.data.session.refresh_expiration).to.be.a('number') + expect(response.data.session.refresh_token).to.not.be.empty + }) + + it('should be refreshed on any api call if access token is expired', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + // Saving the current session information for later... + const sessionBeforeSync = this.application.apiService.getSession() + + // Waiting enough time for the access token to expire, before performing a new sync request. + await sleepUntilSessionExpires(this.application) + + // Performing a sync request with an expired access token. + await this.application.sync.sync(syncOptions) + + // After the above sync request is completed, we obtain the session information. + const sessionAfterSync = this.application.apiService.getSession() + + expect(sessionBeforeSync).to.not.equal(sessionAfterSync) + expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken) + expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken) + expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration) + // New token should expire in the future. + expect(sessionAfterSync.accessExpiration).to.be.greaterThan(Date.now()) + }) + + it('should succeed when a sync request is perfomed after signing into an ephemeral session', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + await this.application.signIn(this.email, this.password, false, true) + + const response = await this.application.apiService.sync([]) + expect(response.status).to.equal(200) + }) + + it('should succeed when a sync request is perfomed after registering into an ephemeral session', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + + const response = await this.application.apiService.sync([]) + expect(response.status).to.equal(200) + }) + + it('should be consistent between storage and apiService', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const sessionFromStorage = await getSessionFromStorage(this.application) + const sessionFromApiService = this.application.apiService.getSession() + + expect(sessionFromStorage).to.equal(sessionFromApiService) + + await this.application.apiService.refreshSession() + + const updatedSessionFromStorage = await getSessionFromStorage(this.application) + const updatedSessionFromApiService = this.application.apiService.getSession() + + expect(updatedSessionFromStorage).to.equal(updatedSessionFromApiService) + }) + + it('should be performed successfully and terminate session with a valid access token', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const signOutResponse = await this.application.apiService.signOut() + expect(signOutResponse.status).to.equal(204) + + Factory.ignoreChallenges(this.application) + const syncResponse = await this.application.apiService.sync([]) + expect(syncResponse.status).to.equal(401) + expect(syncResponse.error.tag).to.equal('invalid-auth') + expect(syncResponse.error.message).to.equal('Invalid login credentials.') + }) + + it('sign out request should be performed successfully and terminate session with expired access token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + // Waiting enough time for the access token to expire, before performing a sign out request. + await sleepUntilSessionExpires(this.application) + + const signOutResponse = await this.application.apiService.signOut() + expect(signOutResponse.status).to.equal(204) + + Factory.ignoreChallenges(this.application) + const syncResponse = await this.application.apiService.sync([]) + expect(syncResponse.status).to.equal(401) + expect(syncResponse.error.tag).to.equal('invalid-auth') + expect(syncResponse.error.message).to.equal('Invalid login credentials.') + }) + + it('change email request should be successful with a valid access token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + let { application, password } = await Factory.createAndInitSimpleAppContext({ + registerUser: true, + }) + const newEmail = UuidGenerator.GenerateUuid() + const changeEmailResponse = await application.changeEmail(newEmail, password) + + expect(changeEmailResponse.status).to.equal(200) + expect(changeEmailResponse.data.user).to.be.ok + + application = await Factory.signOutApplicationAndReturnNew(application) + const loginResponse = await Factory.loginToApplication({ + application: application, + email: newEmail, + password: password, + }) + + expect(loginResponse).to.be.ok + expect(loginResponse.status).to.equal(200) + await Factory.safeDeinit(application) + }) + + it('change email request should fail with an invalid access token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + let { application, password } = await Factory.createAndInitSimpleAppContext({ + registerUser: true, + }) + const fakeSession = application.apiService.getSession() + fakeSession.accessToken = 'this-is-a-fake-token-1234' + Factory.ignoreChallenges(application) + + const newEmail = UuidGenerator.GenerateUuid() + const changeEmailResponse = await application.changeEmail(newEmail, password) + expect(changeEmailResponse.error.message).to.equal('Invalid login credentials.') + + await Factory.safeDeinit(application) + }) + + it('change email request should fail with an expired refresh token', async function () { + this.timeout(Factory.ThirtySecondTimeout) + + let { application, email, password } = await Factory.createAndInitSimpleAppContext({ + registerUser: true, + }) + /** Waiting for the refresh token to expire. */ + await sleepUntilSessionExpires(application, false) + + Factory.ignoreChallenges(application) + const newEmail = UuidGenerator.GenerateUuid() + const changeEmailResponse = await application.changeEmail(newEmail, password) + + expect(changeEmailResponse).to.be.ok + expect(changeEmailResponse.error.message).to.equal('Invalid login credentials.') + + await Factory.safeDeinit(application) + }) + + it('change password request should be successful with a valid access token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) + + expect(changePasswordResponse.status).to.equal(200) + expect(changePasswordResponse.data.user).to.be.ok + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const loginResponse = await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.newPassword, + }) + + expect(loginResponse).to.be.ok + expect(loginResponse.status).to.be.equal(200) + }) + + it('change password request should be successful after the expired access token is refreshed', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + // Waiting enough time for the access token to expire. + await sleepUntilSessionExpires(this.application) + + const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) + + expect(changePasswordResponse).to.be.ok + expect(changePasswordResponse.status).to.equal(200) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const loginResponse = await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.newPassword, + }) + + expect(loginResponse).to.be.ok + expect(loginResponse.status).to.be.equal(200) + }) + + it('change password request should fail with an invalid access token', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const fakeSession = this.application.apiService.getSession() + fakeSession.accessToken = 'this-is-a-fake-token-1234' + Factory.ignoreChallenges(this.application) + const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) + expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.') + }) + + it('change password request should fail with an expired refresh token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + /** Waiting for the refresh token to expire. */ + await sleepUntilSessionExpires(this.application, false) + + Factory.ignoreChallenges(this.application) + const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword) + + expect(changePasswordResponse).to.be.ok + expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.') + }).timeout(25000) + + it('should sign in successfully after signing out', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + await this.application.apiService.signOut() + this.application.apiService.session = undefined + + await this.application.sessionManager.signIn(this.email, this.password) + + const currentSession = this.application.apiService.getSession() + + expect(currentSession).to.be.ok + expect(currentSession.accessToken).to.be.ok + expect(currentSession.refreshToken).to.be.ok + expect(currentSession.accessExpiration).to.be.greaterThan(Date.now()) + }) + + it('should fail when renewing a session with an expired refresh token', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + await sleepUntilSessionExpires(this.application, false) + + const refreshSessionResponse = await this.application.apiService.refreshSession() + + expect(refreshSessionResponse.status).to.equal(400) + expect(refreshSessionResponse.error.tag).to.equal('expired-refresh-token') + expect(refreshSessionResponse.error.message).to.equal('The refresh token has expired.') + + /* + The access token and refresh token should be expired up to this point. + Here we make sure that any subsequent requests will fail. + */ + Factory.ignoreChallenges(this.application) + const syncResponse = await this.application.apiService.sync([]) + expect(syncResponse.status).to.equal(401) + expect(syncResponse.error.tag).to.equal('invalid-auth') + expect(syncResponse.error.message).to.equal('Invalid login credentials.') + }) + + it('should fail when renewing a session with an invalid refresh token', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const fakeSession = this.application.apiService.getSession() + fakeSession.refreshToken = 'this-is-a-fake-token-1234' + + await this.application.apiService.setSession(fakeSession, true) + + const refreshSessionResponse = await this.application.apiService.refreshSession() + + expect(refreshSessionResponse.status).to.equal(400) + expect(refreshSessionResponse.error.tag).to.equal('invalid-refresh-token') + expect(refreshSessionResponse.error.message).to.equal('The refresh token is not valid.') + + // Access token should remain valid. + const syncResponse = await this.application.apiService.sync([]) + expect(syncResponse.status).to.equal(200) + }) + + it('should fail if syncing while a session refresh is in progress', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const refreshPromise = this.application.apiService.refreshSession() + const syncResponse = await this.application.apiService.sync([]) + + expect(syncResponse.error).to.be.ok + + const errorMessage = 'Your account session is being renewed with the server. Please try your request again.' + expect(syncResponse.error.message).to.be.equal(errorMessage) + /** Wait for finish so that test cleans up properly */ + await refreshPromise + }) + + it('notes should be synced as expected after refreshing a session', async function () { + this.timeout(Factory.TwentySecondTimeout) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const notesBeforeSync = await Factory.createManyMappedNotes(this.application, 5) + + await sleepUntilSessionExpires(this.application) + await this.application.syncService.sync(syncOptions) + expect(this.application.syncService.isOutOfSync()).to.equal(false) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + + const expectedNotesUuids = notesBeforeSync.map((n) => n.uuid) + const notesResults = await this.application.itemManager.findItems(expectedNotesUuids) + + expect(notesResults.length).to.equal(notesBeforeSync.length) + + for (const aNoteBeforeSync of notesBeforeSync) { + const noteResult = await this.application.itemManager.findItem(aNoteBeforeSync.uuid) + expect(aNoteBeforeSync.isItemContentEqualWith(noteResult)).to.equal(true) + } + }) + + it('changing password on one client should not invalidate other sessions', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const appA = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await appA.prepareForLaunch({}) + await appA.launch(true) + + const email = `${Math.random()}` + const password = `${Math.random()}` + + await Factory.registerUserToApplication({ + application: appA, + email: email, + password: password, + }) + + /** Create simultaneous appB signed into same account */ + const appB = await Factory.createApplicationWithFakeCrypto('another-namespace') + await appB.prepareForLaunch({}) + await appB.launch(true) + await Factory.loginToApplication({ + application: appB, + email: email, + password: password, + }) + + /** Change password on appB */ + const newPassword = 'random' + await appB.changePassword(password, newPassword) + + /** Create an item and sync it */ + const note = await Factory.createSyncedNote(appB) + + /** Expect appA session to still be valid */ + await appA.sync.sync() + expect(appA.items.findItem(note.uuid)).to.be.ok + + await Factory.safeDeinit(appA) + await Factory.safeDeinit(appB) + }) + + it('should prompt user for account password and sign back in on invalid session', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const email = `${Math.random()}` + const password = `${Math.random()}` + let didPromptForSignIn = false + const receiveChallenge = async (challenge) => { + didPromptForSignIn = true + appA.submitValuesForChallenge(challenge, [ + CreateChallengeValue(challenge.prompts[0], email), + CreateChallengeValue(challenge.prompts[1], password), + ]) + } + const appA = await Factory.createApplicationWithFakeCrypto(Factory.randomString()) + await appA.prepareForLaunch({ receiveChallenge }) + await appA.launch(true) + + await Factory.registerUserToApplication({ + application: appA, + email: email, + password: password, + }) + + const oldRootKey = await appA.protocolService.getRootKey() + + /** Set the session as nonsense */ + appA.apiService.session.accessToken = 'foo' + appA.apiService.session.refreshToken = 'bar' + + /** Perform an authenticated network request */ + await appA.sync.sync() + + /** Allow session recovery to do its thing */ + await Factory.sleep(5.0) + + expect(didPromptForSignIn).to.equal(true) + expect(appA.apiService.session.accessToken).to.not.equal('foo') + expect(appA.apiService.session.refreshToken).to.not.equal('bar') + + /** Expect that the session recovery replaces the global root key */ + const newRootKey = await appA.protocolService.getRootKey() + expect(oldRootKey).to.not.equal(newRootKey) + + await Factory.safeDeinit(appA) + }) + + it('should return current session in list of sessions', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const response = await this.application.apiService.getSessionsList() + expect(response.data[0].current).to.equal(true) + }) + + it('signing out should delete session from all list', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + /** Create new session aside from existing one */ + const app2 = await Factory.createAndInitializeApplication('app2') + await app2.signIn(this.email, this.password) + + const response = await this.application.apiService.getSessionsList() + expect(response.data.length).to.equal(2) + + await app2.user.signOut() + + const response2 = await this.application.apiService.getSessionsList() + expect(response2.data.length).to.equal(1) + }) + + it('revoking a session should destroy local data', async function () { + this.timeout(Factory.TwentySecondTimeout) + + Factory.handlePasswordChallenges(this.application, this.password) + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const app2identifier = 'app2' + const app2 = await Factory.createAndInitializeApplication(app2identifier) + await app2.signIn(this.email, this.password) + const app2Deinit = new Promise((resolve) => { + app2.setOnDeinit(() => { + resolve() + }) + }) + + const { data: sessions } = await this.application.getSessions() + const app2session = sessions.find((session) => !session.current) + await this.application.revokeSession(app2session.uuid) + void app2.sync.sync() + await app2Deinit + + const deviceInterface = new WebDeviceInterface() + const payloads = await deviceInterface.getAllRawDatabasePayloads(app2identifier) + expect(payloads).to.be.empty + }) + + it('revoking other sessions should destroy their local data', async function () { + this.timeout(Factory.TwentySecondTimeout) + + Factory.handlePasswordChallenges(this.application, this.password) + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const app2identifier = 'app2' + const app2 = await Factory.createAndInitializeApplication(app2identifier) + await app2.signIn(this.email, this.password) + const app2Deinit = new Promise((resolve) => { + app2.setOnDeinit(() => { + resolve() + }) + }) + + await this.application.revokeAllOtherSessions() + void app2.sync.sync() + await app2Deinit + + const deviceInterface = new WebDeviceInterface() + const payloads = await deviceInterface.getAllRawDatabasePayloads(app2identifier) + expect(payloads).to.be.empty + }) + + it('signing out with invalid session token should still delete local data', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const invalidSession = this.application.apiService.getSession() + invalidSession.accessToken = undefined + invalidSession.refreshToken = undefined + + const storageKey = this.application.diskStorageService.getPersistenceKey() + expect(localStorage.getItem(storageKey)).to.be.ok + + await this.application.user.signOut() + expect(localStorage.getItem(storageKey)).to.not.be.ok + }) +}) diff --git a/packages/snjs/mocha/settings.test.js b/packages/snjs/mocha/settings.test.js new file mode 100644 index 000000000..fc94b1e85 --- /dev/null +++ b/packages/snjs/mocha/settings.test.js @@ -0,0 +1,111 @@ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('settings service', function () { + const validSetting = SettingName.GoogleDriveBackupFrequency + const fakePayload = 'Im so meta even this acronym' + const updatedFakePayload = 'is meta' + + let application + let context + + beforeEach(async function () { + context = await Factory.createAppContextWithFakeCrypto() + + await context.launch() + + application = context.application + + await Factory.registerUserToApplication({ + application: context.application, + email: context.email, + password: context.password, + }) + }) + + afterEach(async function () { + await Factory.safeDeinit(application) + }) + + it('creates and reads a setting', async function () { + await application.settings.updateSetting(validSetting, fakePayload) + const responseCreate = await application.settings.getSetting(validSetting) + expect(responseCreate).to.equal(fakePayload) + }) + + it('throws error on an invalid setting update', async function () { + const invalidSetting = 'FAKE_SETTING' + let caughtError = undefined + try { + await application.settings.updateSetting(invalidSetting, fakePayload) + } catch (error) { + caughtError = error + } + + expect(caughtError).not.to.equal(undefined) + }) + + it('creates and lists settings', async function () { + await application.settings.updateSetting(validSetting, fakePayload) + const responseList = await application.settings.listSettings() + expect(responseList.getSettingValue(validSetting)).to.eql(fakePayload) + }) + + it('creates and deletes a setting', async function () { + await application.settings.updateSetting(validSetting, fakePayload) + const responseCreate = await application.settings.getSetting(validSetting) + expect(responseCreate).to.eql(fakePayload) + + await application.settings.deleteSetting(validSetting) + const responseDeleted = await application.settings.listSettings() + expect(responseDeleted.getSettingValue(validSetting)).to.not.be.ok + }) + + it('creates and updates a setting', async function () { + await application.settings.updateSetting(validSetting, fakePayload) + await application.settings.updateSetting(validSetting, updatedFakePayload) + const responseUpdated = await application.settings.getSetting(validSetting) + expect(responseUpdated).to.eql(updatedFakePayload) + }) + + it('reads a nonexistent setting', async () => { + const setting = await application.settings.getSetting(validSetting) + expect(setting).to.equal(undefined) + }) + + it('reads a nonexistent sensitive setting', async () => { + const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.MfaSecret) + expect(setting).to.equal(false) + }) + + it('creates and reads a sensitive setting', async () => { + await application.settings.updateSetting(SettingName.MfaSecret, 'fake_secret', true) + const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.MfaSecret) + expect(setting).to.equal(true) + }) + + it('creates and lists a sensitive setting', async () => { + await application.settings.updateSetting(SettingName.MfaSecret, 'fake_secret', true) + await application.settings.updateSetting(SettingName.MuteFailedBackupsEmails, MuteFailedBackupsEmailsOption.Muted) + const settings = await application.settings.listSettings() + expect(settings.getSettingValue(SettingName.MuteFailedBackupsEmails)).to.eql(MuteFailedBackupsEmailsOption.Muted) + expect(settings.getSettingValue(SettingName.MfaSecret)).to.not.be.ok + }) + + it('reads a subscription setting', async () => { + await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', { + userEmail: context.email, + subscriptionId: 1, + subscriptionName: 'PRO_PLAN', + subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000, + timestamp: Date.now(), + offline: false, + }) + + await Factory.sleep(0.5) + + const setting = await application.settings.getSubscriptionSetting('FILE_UPLOAD_BYTES_LIMIT') + expect(setting).to.be.a('string') + }) +}) diff --git a/packages/snjs/mocha/shuffling.html b/packages/snjs/mocha/shuffling.html new file mode 100644 index 000000000..d019fd563 --- /dev/null +++ b/packages/snjs/mocha/shuffling.html @@ -0,0 +1,49 @@ + + + + + + + \ No newline at end of file diff --git a/packages/snjs/mocha/singletons.test.js b/packages/snjs/mocha/singletons.test.js new file mode 100644 index 000000000..e5a88f37a --- /dev/null +++ b/packages/snjs/mocha/singletons.test.js @@ -0,0 +1,345 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +import WebDeviceInterface from './lib/web_device_interface.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('singletons', function () { + this.timeout(Factory.TenSecondTimeout) + + const syncOptions = { + checkIntegrity: true, + } + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + function createPrefsPayload() { + const params = { + uuid: UuidGenerator.GenerateUuid(), + content_type: ContentType.UserPrefs, + content: { + foo: 'bar', + }, + } + return new DecryptedPayload(params) + } + + function findOrCreatePrefsSingleton(application) { + return application.singletonManager.findOrCreateContentTypeSingleton(ContentType.UserPrefs, FillItemContent({})) + } + + beforeEach(async function () { + localStorage.clear() + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + this.registerUser = async () => { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + } + + this.signOut = async () => { + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + } + + this.signIn = async () => { + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + } + + this.extManagerId = 'org.standardnotes.extensions-manager' + + this.extPred = new CompoundPredicate('and', [ + new Predicate('content_type', '=', ContentType.Component), + new Predicate('package_info.identifier', '=', this.extManagerId), + ]) + + this.createExtMgr = () => { + return this.application.itemManager.createItem( + ContentType.Component, + { + package_info: { + name: 'Extensions', + identifier: this.extManagerId, + }, + }, + true, + ) + } + }) + + afterEach(async function () { + expect(this.application.syncService.isOutOfSync()).to.equal(false) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + + await Factory.safeDeinit(this.application) + + localStorage.clear() + }) + + it(`only resolves to ${BASE_ITEM_COUNT} items`, async function () { + /** Preferences are an item we know to always return true for isSingleton */ + const prefs1 = createPrefsPayload() + const prefs2 = createPrefsPayload() + const prefs3 = createPrefsPayload() + + const items = await this.application.itemManager.emitItemsFromPayloads( + [prefs1, prefs2, prefs3], + PayloadEmitSource.LocalChanged, + ) + await this.application.itemManager.setItemsDirty(items) + await this.application.syncService.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('duplicate components should auto-resolve to 1', async function () { + const extManager = await this.createExtMgr() + this.expectedItemCount += 1 + + /** Call needlessly */ + await this.createExtMgr() + await this.createExtMgr() + await this.createExtMgr() + + expect(extManager).to.be.ok + + const refreshedExtMgr = this.application.items.findItem(extManager.uuid) + + expect(refreshedExtMgr).to.be.ok + + await this.application.sync.sync(syncOptions) + + expect(this.application.itemManager.itemsMatchingPredicate(ContentType.Component, this.extPred).length).to.equal(1) + }) + + it('resolves via find or create', async function () { + /* Set to never synced as singleton manager will attempt to sync before resolving */ + this.application.syncService.ut_clearLastSyncDate() + this.application.syncService.ut_setDatabaseLoaded(false) + const contentType = ContentType.UserPrefs + const predicate = new Predicate('content_type', '=', contentType) + /* Start a sync right after we await singleton resolve below */ + setTimeout(() => { + this.application.syncService.ut_setDatabaseLoaded(true) + this.application.sync.sync({ + /* Simulate the first sync occuring as that is handled specially by sync service */ + mode: SyncMode.DownloadFirst, + }) + }) + const userPreferences = await this.application.singletonManager.findOrCreateContentTypeSingleton(contentType, {}) + + expect(userPreferences).to.be.ok + const refreshedUserPrefs = this.application.items.findItem(userPreferences.uuid) + expect(refreshedUserPrefs).to.be.ok + await this.application.sync.sync(syncOptions) + expect(this.application.itemManager.itemsMatchingPredicate(contentType, predicate).length).to.equal(1) + }) + + it('resolves registered predicate with signing in/out', async function () { + await this.registerUser() + + await this.signOut() + + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + + await this.createExtMgr() + + this.expectedItemCount += 1 + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + await this.signOut() + + await this.createExtMgr() + + await this.application.sync.sync(syncOptions) + + const extraSync = this.application.sync.sync(syncOptions) + + await this.signIn() + + await extraSync + }).timeout(15000) + + it('singletons that are deleted after download first sync should not sync to server', async function () { + await this.registerUser() + await this.createExtMgr() + await this.createExtMgr() + await this.createExtMgr() + this.expectedItemCount++ + + let didCompleteRelevantSync = false + let beginCheckingResponse = false + this.application.syncService.addEventObserver(async (eventName, data) => { + if (eventName === SyncEvent.DownloadFirstSyncCompleted) { + beginCheckingResponse = true + } + if (!beginCheckingResponse) { + return + } + if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + didCompleteRelevantSync = true + const saved = data.savedPayloads + expect(saved.length).to.equal(1) + const matching = saved.find((p) => p.content_type === ContentType.Component && p.deleted) + expect(matching).to.not.be.ok + } + }) + await this.application.syncService.sync({ mode: SyncMode.DownloadFirst }) + expect(didCompleteRelevantSync).to.equal(true) + }).timeout(10000) + + it('signing into account and retrieving singleton shouldnt put us in deadlock', async function () { + await this.registerUser() + + /** Create prefs */ + const ogPrefs = await findOrCreatePrefsSingleton(this.application) + await this.application.sync.sync(syncOptions) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + /** Create another instance while signed out */ + await findOrCreatePrefsSingleton(this.application) + await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + /** After signing in, the instance retrieved from the server should be the one kept */ + const latestPrefs = await findOrCreatePrefsSingleton(this.application) + expect(latestPrefs.uuid).to.equal(ogPrefs.uuid) + + const allPrefs = this.application.itemManager.getItems(ogPrefs.content_type) + expect(allPrefs.length).to.equal(1) + }) + + it('resolving singleton before first sync, then signing in, should result in correct number of instances', async function () { + await this.registerUser() + /** Create prefs and associate them with account */ + const ogPrefs = await findOrCreatePrefsSingleton(this.application) + await this.application.sync.sync(syncOptions) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + /** Create another instance while signed out */ + await findOrCreatePrefsSingleton(this.application) + await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + /** After signing in, the instance retrieved from the server should be the one kept */ + const latestPrefs = await findOrCreatePrefsSingleton(this.application) + expect(latestPrefs.uuid).to.equal(ogPrefs.uuid) + const allPrefs = this.application.itemManager.getItems(ogPrefs.content_type) + expect(allPrefs.length).to.equal(1) + }) + + it('if only result is errorDecrypting, create new item', async function () { + const item = this.application.itemManager.items.find((item) => item.content_type === ContentType.UserPrefs) + + const erroredPayload = new EncryptedPayload({ + ...item.payload.ejected(), + content: '004:...', + errorDecrypting: true, + }) + + await this.application.payloadManager.emitPayload(erroredPayload) + + const resolvedItem = await this.application.singletonManager.findOrCreateContentTypeSingleton( + item.content_type, + item.content, + ) + + await this.application.sync.sync({ awaitAll: true }) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + expect(resolvedItem.uuid).to.not.equal(item.uuid) + expect(resolvedItem.errorDecrypting).to.not.be.ok + }) + + it('if two items and one is error decrypting, should resolve after download first sync', async function () { + /** + * While signing in, a singleton item may be inserted that hasn't yet had the chance to decrypt + * When the singleton logic runs, it will ignore this item, and matching singletons will result + * in just 1, meaning the two items will not be consolidated. We want to make sure that when the item + * is then subsequently decrypted, singleton logic runs again for the item. + */ + + const sharedContent = { + package_info: { + name: 'Extensions', + identifier: this.extManagerId, + }, + } + + const errorDecryptingFalse = false + await Factory.insertItemWithOverride( + this.application, + ContentType.Component, + sharedContent, + true, + errorDecryptingFalse, + ) + + const errorDecryptingTrue = true + const errored = await Factory.insertItemWithOverride( + this.application, + ContentType.Component, + sharedContent, + true, + errorDecryptingTrue, + ) + + this.expectedItemCount += 1 + + await this.application.sync.sync(syncOptions) + + /** Now mark errored as not errorDecrypting and sync */ + const notErrored = new DecryptedPayload({ + ...errored.payload, + content: sharedContent, + errorDecrypting: false, + }) + + await this.application.payloadManager.emitPayload(notErrored) + + /** Item will get decrypted on current tick, so wait one before syncing */ + await Factory.sleep(0) + await this.application.syncService.sync(syncOptions) + + expect(this.application.itemManager.itemsMatchingPredicate(ContentType.Component, this.extPred).length).to.equal(1) + }) + + it('alternating the uuid of a singleton should return correct result', async function () { + const payload = createPrefsPayload() + const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + await this.application.syncService.sync(syncOptions) + const predicate = new Predicate('content_type', '=', item.content_type) + let resolvedItem = await this.application.singletonManager.findOrCreateContentTypeSingleton( + payload.content_type, + payload.content, + ) + const originalUuid = resolvedItem.uuid + await Factory.alternateUuidForItem(this.application, resolvedItem.uuid) + await this.application.syncService.sync(syncOptions) + const resolvedItem2 = await this.application.singletonManager.findOrCreateContentTypeSingleton( + payload.content_type, + payload.content, + ) + resolvedItem = this.application.items.findItem(resolvedItem.uuid) + expect(resolvedItem).to.not.be.ok + expect(resolvedItem2.uuid).to.not.equal(originalUuid) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) +}) diff --git a/packages/snjs/mocha/storage.test.js b/packages/snjs/mocha/storage.test.js new file mode 100644 index 000000000..fab85a1e1 --- /dev/null +++ b/packages/snjs/mocha/storage.test.js @@ -0,0 +1,304 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('storage manager', function () { + this.timeout(Factory.TenSecondTimeout) + /** + * Items are saved in localStorage in tests. + * Base keys are `storage`, `snjs_version`, and `keychain` + */ + const BASE_KEY_COUNT = 3 + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + beforeEach(async function () { + localStorage.clear() + this.expectedKeyCount = BASE_KEY_COUNT + this.application = await Factory.createInitAppWithFakeCrypto(Environment.Mobile) + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + it('should set and retrieve values', async function () { + const key = 'foo' + const value = 'bar' + await this.application.diskStorageService.setValue(key, value) + expect(await this.application.diskStorageService.getValue(key)).to.eql(value) + }) + + it('should set and retrieve items', async function () { + const payload = Factory.createNotePayload() + await this.application.diskStorageService.savePayload(payload) + const payloads = await this.application.diskStorageService.getAllRawPayloads() + expect(payloads.length).to.equal(BASE_ITEM_COUNT + 1) + }) + + it('should clear values', async function () { + const key = 'foo' + const value = 'bar' + await this.application.diskStorageService.setValue(key, value) + await this.application.diskStorageService.clearAllData() + expect(await this.application.diskStorageService.getValue(key)).to.not.be.ok + }) + + it('serverPassword should not be saved to keychain', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + const keychainValue = await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier) + expect(keychainValue.masterKey).to.be.ok + expect(keychainValue.serverPassword).to.not.be.ok + }) + + it.skip('regular session should persist data', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + const key = 'foo' + const value = 'bar' + await this.application.diskStorageService.setValue(key, value) + /** Items are stored in local storage */ + expect(Object.keys(localStorage).length).to.equal(this.expectedKeyCount + BASE_ITEM_COUNT) + const retrievedValue = await this.application.diskStorageService.getValue(key) + expect(retrievedValue).to.equal(value) + }) + + it('ephemeral session should not persist data', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + const key = 'foo' + const value = 'bar' + await this.application.diskStorageService.setValueAndAwaitPersist(key, value) + expect(Object.keys(localStorage).length).to.equal(0) + const retrievedValue = await this.application.diskStorageService.getValue(key) + expect(retrievedValue).to.equal(value) + }) + + it('ephemeral session should not persist to database', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + await Factory.createSyncedNote(this.application) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(0) + }) + + it('storage with no account and no passcode should not be encrypted', async function () { + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped] + const payload = new DecryptedPayload(wrappedValue) + expect(payload.content).to.be.an.instanceof(Object) + }) + + it('storage aftering adding passcode should be encrypted', async function () { + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + await this.application.addPasscode('123') + const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped] + const payload = new EncryptedPayload(wrappedValue) + expect(payload.content).to.be.a('string') + }) + + it('storage after adding passcode then removing passcode should not be encrypted', async function () { + const passcode = '123' + Factory.handlePasswordChallenges(this.application, passcode) + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + await this.application.addPasscode(passcode) + await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo') + await this.application.removePasscode() + const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped] + const payload = new DecryptedPayload(wrappedValue) + expect(payload.content).to.be.an.instanceof(Object) + }) + + it('storage aftering adding passcode/removing passcode w/account should be encrypted', async function () { + const passcode = '123' + /** + * After setting passcode, we expect that the keychain has been cleared, as the account keys + * are now wrapped in storage with the passcode. Once the passcode is removed, we expect + * the account keys to be moved to the keychain. + * */ + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.be.ok + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + Factory.handlePasswordChallenges(this.application, this.password) + await this.application.addPasscode(passcode) + expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.not.be.ok + await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo') + Factory.handlePasswordChallenges(this.application, passcode) + await this.application.removePasscode() + expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.be.ok + + const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped] + const payload = new EncryptedPayload(wrappedValue) + expect(payload.content).to.be.a('string') + }) + + it('adding account should encrypt storage with account keys', async function () { + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + const accountKey = await this.application.protocolService.getRootKey() + expect(await this.application.diskStorageService.canDecryptWithKey(accountKey)).to.equal(true) + }) + + it('signing out of account should decrypt storage', async function () { + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo') + const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped] + const payload = new DecryptedPayload(wrappedValue) + expect(payload.content).to.be.an.instanceof(Object) + }) + + it('adding account then passcode should encrypt storage with account keys', async function () { + /** Should encrypt storage with account keys and encrypt account keys with passcode */ + await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar') + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: true, + }) + + /** Should not be wrapped root key yet */ + expect(await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()).to.not.be.ok + + const passcode = '123' + Factory.handlePasswordChallenges(this.application, this.password) + await this.application.addPasscode(passcode) + await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo') + + /** Root key should now be wrapped */ + expect(await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()).to.be.ok + + const accountKey = await this.application.protocolService.getRootKey() + expect(await this.application.diskStorageService.canDecryptWithKey(accountKey)).to.equal(true) + const passcodeKey = await this.application.protocolService.computeWrappingKey(passcode) + const wrappedRootKey = await this.application.protocolService.rootKeyEncryption.getWrappedRootKey() + /** Expect that we can decrypt wrapped root key with passcode key */ + const payload = new EncryptedPayload(wrappedRootKey) + const decrypted = await this.application.protocolService.decryptSplitSingle({ + usesRootKey: { + items: [payload], + key: passcodeKey, + }, + }) + expect(decrypted.content).to.be.an.instanceof(Object) + }) + + it('disabling storage encryption should store items without encryption', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + + await this.application.setStorageEncryptionPolicy(StorageEncryptionPolicy.Disabled) + + const payloads = await this.application.diskStorageService.getAllRawPayloads() + const payload = payloads[0] + expect(typeof payload.content).to.not.equal('string') + expect(payload.content.references).to.be.ok + + const identifier = this.application.identifier + + const app = await Factory.createAndInitializeApplication(identifier, Environment.Mobile) + expect(app.diskStorageService.encryptionPolicy).to.equal(StorageEncryptionPolicy.Disabled) + }) + + it('stored payloads should not contain metadata fields', async function () { + await this.application.addPasscode('123') + await Factory.createSyncedNote(this.application) + const payloads = await this.application.diskStorageService.getAllRawPayloads() + const payload = payloads[0] + expect(payload.fields).to.not.be.ok + expect(payload.source).to.not.be.ok + expect(payload.format).to.not.be.ok + }) + + it('storing an offline synced payload should not include dirty flag', async function () { + await this.application.addPasscode('123') + await Factory.createSyncedNote(this.application) + const payloads = await this.application.diskStorageService.getAllRawPayloads() + const payload = payloads[0] + + expect(payload.dirty).to.not.be.ok + }) + + it('storing an online synced payload should not include dirty flag', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + + await Factory.createSyncedNote(this.application) + const payloads = await this.application.diskStorageService.getAllRawPayloads() + const payload = payloads[0] + + expect(payload.dirty).to.not.be.ok + }) + + it('signing out should clear unwrapped value store', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const values = this.application.diskStorageService.values[ValueModesKeys.Unwrapped] + expect(Object.keys(values).length).to.equal(0) + }) + + it('signing out should clear payloads', async function () { + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + ephemeral: false, + }) + + await Factory.createSyncedNote(this.application) + expect(await Factory.storagePayloadCount(this.application)).to.equal(BASE_ITEM_COUNT + 1) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + expect(await Factory.storagePayloadCount(this.application)).to.equal(BASE_ITEM_COUNT) + }) +}) diff --git a/packages/snjs/mocha/sync_tests/conflicting.test.js b/packages/snjs/mocha/sync_tests/conflicting.test.js new file mode 100644 index 000000000..f94156d3c --- /dev/null +++ b/packages/snjs/mocha/sync_tests/conflicting.test.js @@ -0,0 +1,1004 @@ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import { createItemParams, createSyncedNoteWithTag } from '../lib/Items.js' +import * as Utils from '../lib/Utils.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('online conflict handling', function () { + this.timeout(Factory.TenSecondTimeout) + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + localStorage.clear() + this.expectedItemCount = BASE_ITEM_COUNT + + this.context = await Factory.createAppContextWithFakeCrypto('AppA') + await this.context.launch() + + this.application = this.context.application + this.email = this.context.email + this.password = this.context.password + + Factory.disableIntegrityAutoHeal(this.application) + + await this.context.register() + + this.sharedFinalAssertions = async function () { + expect(this.application.syncService.isOutOfSync()).to.equal(false) + const items = this.application.itemManager.items + expect(items.length).to.equal(this.expectedItemCount) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + } + }) + + afterEach(async function () { + if (!this.application.dealloced) { + await Factory.safeDeinit(this.application) + } + localStorage.clear() + }) + + function createDirtyPayload(contentType) { + const params = { + uuid: UuidGenerator.GenerateUuid(), + content_type: contentType, + content: { + foo: 'bar', + }, + } + const payload = new DecryptedPayload({ + ...params, + dirty: true, + dirtyIndex: getIncrementedDirtyIndex(), + }) + return payload + } + + it('components should not be duplicated under any circumstances', async function () { + const payload = createDirtyPayload(ContentType.Component) + + const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + + this.expectedItemCount++ + + await this.application.syncService.sync(syncOptions) + + /** First modify the item without saving so that our local contents digress from the server's */ + await this.application.mutator.changeItem(item, (mutator) => { + mutator.mutableContent.foo = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + item.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + foo: 'zar', + }, + syncOptions, + ) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('items keys should not be duplicated under any circumstances', async function () { + const payload = createDirtyPayload(ContentType.ItemsKey) + const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + this.expectedItemCount++ + await this.application.syncService.sync(syncOptions) + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(item, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + item.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + foo: 'zar', + }, + syncOptions, + ) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('duplicating note should maintain editor ref', async function () { + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + const basePayload = createDirtyPayload(ContentType.Component) + const payload = basePayload.copy({ + content: { + ...basePayload.content, + area: ComponentArea.Editor, + }, + }) + const editor = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged) + this.expectedItemCount++ + await this.application.syncService.sync(syncOptions) + + await this.application.mutator.changeAndSaveItem( + editor, + (mutator) => { + mutator.associateWithItem(note.uuid) + }, + undefined, + undefined, + syncOptions, + ) + + expect(this.application.componentManager.editorForNote(note)).to.be.ok + + /** Conflict the note */ + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: 'zar', + }, + syncOptions, + ) + + this.expectedItemCount++ + + const duplicate = this.application.itemManager.getDisplayableNotes().find((n) => { + return n.uuid !== note.uuid + }) + expect(duplicate).to.be.ok + expect(this.application.componentManager.editorForNote(duplicate)).to.be.ok + await this.sharedFinalAssertions() + }) + + it('should create conflicted copy if incoming server item attempts to overwrite local dirty item', async function () { + // create an item and sync it + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + + const originalValue = note.title + const dirtyValue = `${Math.random()}` + + /** Modify nonsense first to get around strategyWhenConflictingWithItem with previousRevision check */ + await this.application.itemManager.changeNote(note, (mutator) => { + mutator.title = 'any' + }) + + await this.application.itemManager.changeNote(note, (mutator) => { + // modify this item locally to have differing contents from server + mutator.title = dirtyValue + // Intentionally don't change updated_at. We want to simulate a chaotic case where + // for some reason we receive an item with different content but the same updated_at. + // note.updated_at = Factory.yesterday(); + }) + + // Download all items from the server, which will include this note. + await this.application.syncService.clearSyncPositionTokens() + await this.application.syncService.sync({ + ...syncOptions, + awaitAll: true, + }) + + // We expect this item to be duplicated + this.expectedItemCount++ + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) + + const allItems = this.application.itemManager.items + expect(allItems.length).to.equal(this.expectedItemCount) + + const originalItem = this.application.itemManager.findItem(note.uuid) + const duplicateItem = allItems.find((i) => i.content.conflict_of === note.uuid) + + expect(originalItem.title).to.equal(dirtyValue) + expect(duplicateItem.title).to.equal(originalValue) + expect(originalItem.title).to.not.equal(duplicateItem.title) + + const newRawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(newRawPayloads.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('should handle sync conflicts by duplicating differing data', async function () { + // create an item and sync it + const note = await Factory.createMappedNote(this.application) + await Factory.markDirtyAndSyncItem(this.application, note) + this.expectedItemCount++ + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: `${Math.random()}`, + }, + syncOptions, + ) + + // We expect this item to be duplicated + this.expectedItemCount++ + const allItems = this.application.itemManager.items + expect(allItems.length).to.equal(this.expectedItemCount) + + const note1 = this.application.itemManager.getDisplayableNotes()[0] + const note2 = this.application.itemManager.getDisplayableNotes()[1] + expect(note1.content.title).to.not.equal(note2.content.title) + await this.sharedFinalAssertions() + }) + + it('basic conflict with clearing local state', async function () { + const note = await Factory.createMappedNote(this.application) + await Factory.markDirtyAndSyncItem(this.application, note) + this.expectedItemCount += 1 + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: `${Math.random()}`, + }, + syncOptions, + ) + + this.expectedItemCount++ + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + // clear sync token, clear storage, download all items, and ensure none of them have error decrypting + await this.application.syncService.clearSyncPositionTokens() + await this.application.diskStorageService.clearAllPayloads() + await this.application.payloadManager.resetState() + await this.application.itemManager.resetState() + await this.application.syncService.sync(syncOptions) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('should duplicate item if saving a modified item and clearing our sync token', async function () { + let note = await Factory.createMappedNote(this.application) + + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + this.expectedItemCount++ + + const newTitle = `${Math.random()}` + + /** First modify the item without saving so that our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + await Factory.changePayloadTimeStamp( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: newTitle, + }, + syncOptions, + ) + + // We expect this item to be duplicated + this.expectedItemCount++ + + await this.application.syncService.clearSyncPositionTokens() + await this.application.syncService.sync(syncOptions) + + note = this.application.items.findItem(note.uuid) + + // We expect the item title to be the new title, and not rolled back to original value + expect(note.content.title).to.equal(newTitle) + + const allItems = this.application.itemManager.items + expect(allItems.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('should handle sync conflicts by not duplicating same data', async function () { + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + // keep item as is and set dirty + await this.application.itemManager.setItemDirty(note) + + // clear sync token so that all items are retrieved on next sync + this.application.syncService.clearSyncPositionTokens() + + await this.application.syncService.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('clearing conflict_of on two clients simultaneously should keep us in sync', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + await this.application.mutator.changeAndSaveItem( + note, + (mutator) => { + // client A + mutator.mutableContent.conflict_of = 'foo' + }, + undefined, + undefined, + syncOptions, + ) + + // client B + await this.application.syncService.clearSyncPositionTokens() + await this.application.itemManager.changeItem( + note, + (mutator) => { + mutator.mutableContent.conflict_of = 'bar' + }, + undefined, + undefined, + syncOptions, + ) + + // conflict_of is a key to ignore when comparing content, so item should + // not be duplicated. + await this.application.syncService.sync(syncOptions) + await this.sharedFinalAssertions() + }) + + it('setting property on two clients simultaneously should create conflict', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + await this.application.mutator.changeAndSaveItem( + note, + (mutator) => { + // client A + mutator.mutableContent.foo = 'foo' + }, + undefined, + undefined, + syncOptions, + ) + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + // client B + await this.application.syncService.clearSyncPositionTokens() + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + foo: 'bar', + }, + syncOptions, + ) + + this.expectedItemCount++ + + await this.sharedFinalAssertions() + }) + + it('if server says deleted but client says not deleted, keep server state', async function () { + const note = await Factory.createMappedNote(this.application) + const originalPayload = note.payloadRepresentation() + this.expectedItemCount++ + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + // client A + await this.application.itemManager.setItemToBeDeleted(note) + await this.application.syncService.sync(syncOptions) + this.expectedItemCount-- + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + // client B + await this.application.syncService.clearSyncPositionTokens() + // Add the item back and say it's not deleted + const mutatedPayload = new DecryptedPayload({ + ...originalPayload, + deleted: false, + updated_at: Factory.yesterday(), + }) + await this.application.itemManager.emitItemsFromPayloads([mutatedPayload], PayloadEmitSource.LocalChanged) + const resultNote = this.application.itemManager.findItem(note.uuid) + expect(resultNote.uuid).to.equal(note.uuid) + await this.application.itemManager.setItemDirty(resultNote) + await this.application.syncService.sync(syncOptions) + + // We expect that this item is now gone for good, and a duplicate has not been created. + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('if server says not deleted but client says deleted, keep server state', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + // client A + await this.application.syncService.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + // client B + await this.application.syncService.clearSyncPositionTokens() + + // This client says this item is deleted, but the server is saying its not deleted. + // In this case, we want to keep the server copy. + await Factory.changePayloadTimeStampDeleteAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + syncOptions, + ) + + // We expect that this item maintained. + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + it('should create conflict if syncing an item that is stale', async function () { + let note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + note = this.application.items.findItem(note.uuid) + expect(note.dirty).to.equal(false) + this.expectedItemCount++ + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + note = await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + text: 'Stale text', + }, + syncOptions, + ) + + expect(note.dirty).to.equal(false) + + // We expect now that the item was conflicted + this.expectedItemCount++ + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + for (const payload of rawPayloads) { + expect(payload.dirty).to.not.be.ok + } + await this.sharedFinalAssertions() + }) + + it('creating conflict with exactly equal content should keep us in sync', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + await this.application.syncService.sync(syncOptions) + + await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + {}, + syncOptions, + ) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await this.sharedFinalAssertions() + }) + + /** Temporarily skipping due to long run time */ + it.skip('handles stale data in bulk', async function () { + /** This number must be greater than the pagination limit per sync request. + * For example if the limit per request is 150 items sent/received, this number should + * be something like 160. */ + const largeItemCount = SyncUpDownLimit + 10 + await Factory.createManyMappedNotes(this.application, largeItemCount) + + /** Upload */ + this.application.syncService.sync(syncOptions) + await this.context.awaitNextSucessfulSync() + + this.expectedItemCount += largeItemCount + const items = this.application.itemManager.items + expect(items.length).to.equal(this.expectedItemCount) + + /** + * We want to see what will happen if we upload everything we have to + * the server as dirty, with no sync token, so that the server also + * gives us everything it has. + */ + this.application.syncService.lockSyncing() + const yesterday = Factory.yesterday() + for (const note of this.application.itemManager.getDisplayableNotes()) { + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = '1' + }) + + await Factory.changePayloadTimeStamp(this.application, note.payload, Factory.dateToMicroseconds(yesterday), { + text: '2', + }) + + // We expect all the notes to be duplicated. + this.expectedItemCount++ + } + this.application.syncService.unlockSyncing() + + await this.application.syncService.clearSyncPositionTokens() + this.application.syncService.sync(syncOptions) + await this.context.awaitNextSucessfulSync() + + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(largeItemCount * 2) + await this.sharedFinalAssertions() + }).timeout(60000) + + it('duplicating an item should maintian its relationships', async function () { + const payload1 = Factory.createStorageItemPayload(ContentType.Tag) + const payload2 = Factory.createStorageItemPayload(ContentType.UserPrefs) + this.expectedItemCount -= 1 /** auto-created user preferences */ + await this.application.itemManager.emitItemsFromPayloads([payload1, payload2], PayloadEmitSource.LocalChanged) + this.expectedItemCount += 2 + let tag = this.application.itemManager.getItems(ContentType.Tag)[0] + let userPrefs = this.application.itemManager.getItems(ContentType.UserPrefs)[0] + expect(tag).to.be.ok + expect(userPrefs).to.be.ok + + tag = await this.application.itemManager.changeItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(userPrefs) + }) + + await this.application.itemManager.setItemDirty(userPrefs) + userPrefs = this.application.items.findItem(userPrefs.uuid) + + expect(this.application.itemManager.itemsReferencingItem(userPrefs).length).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(userPrefs)).to.include(tag) + + await this.application.syncService.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + tag = await Factory.changePayloadTimeStamp( + this.application, + tag.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: `${Math.random()}`, + }, + ) + + await this.application.syncService.sync({ ...syncOptions, awaitAll: true }) + + // fooItem should now be conflicted and a copy created + this.expectedItemCount++ + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + + const fooItems = this.application.itemManager.getItems(ContentType.Tag) + const fooItem2 = fooItems[1] + + expect(fooItem2.content.conflict_of).to.equal(tag.uuid) + // Two items now link to this original object + const referencingItems = this.application.itemManager.itemsReferencingItem(userPrefs) + expect(referencingItems.length).to.equal(2) + expect(referencingItems[0]).to.not.equal(referencingItems[1]) + + expect(this.application.itemManager.itemsReferencingItem(tag).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(fooItem2).length).to.equal(0) + + expect(tag.content.references.length).to.equal(1) + expect(fooItem2.content.references.length).to.equal(1) + expect(userPrefs.content.references.length).to.equal(0) + + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + for (const item of this.application.itemManager.items) { + expect(item.dirty).to.not.be.ok + } + await this.sharedFinalAssertions() + }) + + it('when a note is conflicted, its tags should not be duplicated.', async function () { + /** + * If you have a note and a tag, and the tag has 1 reference to the note, + * and you import the same two items, except modify the note value so that + * a duplicate is created, we expect only the note to be duplicated, + * and the tag not to. + */ + let tag = await Factory.createMappedTag(this.application) + let note = await Factory.createMappedNote(this.application) + tag = await this.application.mutator.changeAndSaveItem( + tag, + (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note) + }, + undefined, + undefined, + syncOptions, + ) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount += 2 + + await this.application.syncService.sync(syncOptions) + + // conflict the note + const newText = `${Math.random()}` + + /** First modify the item without saving so that our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + + note = await Factory.changePayloadTimeStampAndSync( + this.application, + note.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + text: newText, + }, + syncOptions, + ) + + // conflict the tag but keep its content the same + tag = await Factory.changePayloadTimeStampAndSync( + this.application, + tag.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + {}, + syncOptions, + ) + + /** + * We expect now that the total item count has went up by just 1 (the note), + * and not 2 (the note and tag) + */ + this.expectedItemCount += 1 + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + expect(tag.content.references.length).to.equal(2) + await this.sharedFinalAssertions() + }) + + it('succesful server side saving but dropped packet response should not create sync conflict', async function () { + /** + * 1. Initiate a change locally that is successfully saved by the server, but the client + * drops the server response. + * 2. Make a change to this note locally that then syncs and the response is successfully recorded. + * + * Expected result: no sync conflict is created + */ + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + + const baseTitle = 'base title' + /** Change the note */ + const noteAfterChange = await this.application.itemManager.changeItem(note, (mutator) => { + mutator.title = baseTitle + }) + await this.application.sync.sync() + + /** Simulate a dropped response by reverting the note back its post-change, pre-sync state */ + const retroNote = await this.application.itemManager.emitItemFromPayload(noteAfterChange.payload) + expect(retroNote.serverUpdatedAt.getTime()).to.equal(noteAfterChange.serverUpdatedAt.getTime()) + + /** Change the item to its final title and sync */ + const finalTitle = 'final title' + await this.application.itemManager.changeItem(note, (mutator) => { + mutator.title = finalTitle + }) + await this.application.sync.sync() + + /** Expect that no duplicates have been created, and that the note's title is now finalTitle */ + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + const finalNote = this.application.items.findItem(note.uuid) + expect(finalNote.title).to.equal(finalTitle) + await this.sharedFinalAssertions() + }) + + it('receiving a decrypted item while the current local item is errored and dirty should overwrite local value', async function () { + /** + * An item can be marked as dirty (perhaps via a bulk dirtying operation) even if it is errored, + * but it can never be sent to the server if errored. If we retrieve an item from the server + * that we're able to decrypt, and the current base value is errored and dirty, we don't want to + * create a conflict, but instead just have the server value replace the client value. + */ + /** + * Create a note and sync it with the server while its valid + */ + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + + /** + * Mark the item as dirty and errored + */ + const errorred = new EncryptedPayload({ + ...note.payload, + content: '004:...', + errorDecrypting: true, + dirty: true, + }) + await this.application.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + + /** + * Retrieve this note from the server by clearing sync token + */ + await this.application.syncService.clearSyncPositionTokens() + await this.application.syncService.sync({ + ...syncOptions, + awaitAll: true, + }) + + /** + * Expect that the final result is just 1 note that is not errored + */ + const resultNote = await this.application.items.findItem(note.uuid) + expect(resultNote.errorDecrypting).to.not.be.ok + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + await this.sharedFinalAssertions() + }) + + /** Temporarily skipping due to long run time */ + it.skip( + 'registering for account with bulk offline data belonging to another account should be error-free', + async function () { + /** + * When performing a multi-page sync request where we are uploading data imported from a backup, + * if the first page of the sync request returns conflicted items keys, we rotate their UUID. + * The second page of sync waiting to be sent up is still encrypted with the old items key UUID. + * This causes a problem because when that second page is returned as conflicts, we will be looking + * for an items_key_id that no longer exists (has been rotated). Rather than modifying the entire + * sync paradigm to allow multi-page requests to consider side-effects of each page, we will instead + * take the approach of making sure the decryption function is liberal with regards to searching + * for the right items key. It will now consider (as a result of this test) an items key as being + * the correct key to decrypt an item if the itemskey.uuid == item.items_key_id OR if the itemsKey.duplicateOf + * value is equal to item.items_key_id. + */ + + /** Create bulk data belonging to another account and sync */ + const largeItemCount = SyncUpDownLimit + 10 + await Factory.createManyMappedNotes(this.application, largeItemCount) + await this.application.syncService.sync(syncOptions) + const priorData = this.application.itemManager.items + + /** Register new account and import this same data */ + const newApp = await Factory.signOutApplicationAndReturnNew(this.application) + await Factory.registerUserToApplication({ + application: newApp, + email: Utils.generateUuid(), + password: Utils.generateUuid(), + }) + await newApp.itemManager.emitItemsFromPayloads(priorData.map((i) => i.payload)) + await newApp.syncService.markAllItemsAsNeedingSyncAndPersist() + await newApp.syncService.sync(syncOptions) + expect(newApp.payloadManager.invalidPayloads.length).to.equal(0) + await Factory.safeDeinit(newApp) + }, + ).timeout(80000) + + it('importing data belonging to another account should not result in duplication', async function () { + /** Create primary account and export data */ + await createSyncedNoteWithTag(this.application) + let backupFile = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + /** Sort matters, and is the cause of the original issue, where tag comes before the note */ + backupFile.items = [ + backupFile.items.find((i) => i.content_type === ContentType.ItemsKey), + backupFile.items.find((i) => i.content_type === ContentType.Tag), + backupFile.items.find((i) => i.content_type === ContentType.Note), + ] + backupFile = JSON.parse(JSON.stringify(backupFile)) + /** Register new account and import this same data */ + const newApp = await Factory.signOutApplicationAndReturnNew(this.application) + const password = this.password + await Factory.registerUserToApplication({ + application: newApp, + email: Utils.generateUuid(), + password: password, + }) + Factory.handlePasswordChallenges(newApp, password) + await newApp.mutator.importData(backupFile, true) + expect(newApp.itemManager.getDisplayableTags().length).to.equal(1) + expect(newApp.itemManager.getDisplayableNotes().length).to.equal(1) + await Factory.safeDeinit(newApp) + }).timeout(10000) + + it('importing notes + tags belonging to another account should keep correct associations', async function () { + /** + * The original issue can be replicated when an export contains a tag with two notes, + * where the two notes are first listed in the backup, then the tag. + */ + /** Create primary account and export data */ + await createSyncedNoteWithTag(this.application) + const tag = this.application.itemManager.getDisplayableTags()[0] + const note2 = await Factory.createMappedNote(this.application) + await this.application.mutator.changeAndSaveItem(tag, (mutator) => { + mutator.e2ePendingRefactor_addItemAsRelationship(note2) + }) + let backupFile = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups() + backupFile.items = [ + backupFile.items.find((i) => i.content_type === ContentType.ItemsKey), + backupFile.items.filter((i) => i.content_type === ContentType.Note)[0], + backupFile.items.filter((i) => i.content_type === ContentType.Note)[1], + backupFile.items.find((i) => i.content_type === ContentType.Tag), + ] + backupFile = JSON.parse(JSON.stringify(backupFile)) + /** Register new account and import this same data */ + const newApp = await Factory.signOutApplicationAndReturnNew(this.application) + const password = this.password + await Factory.registerUserToApplication({ + application: newApp, + email: Utils.generateUuid(), + password: password, + }) + Factory.handlePasswordChallenges(newApp, password) + await newApp.mutator.importData(backupFile, true) + const newTag = newApp.itemManager.getDisplayableTags()[0] + const notes = newApp.items.referencesForItem(newTag) + expect(notes.length).to.equal(2) + await Factory.safeDeinit(newApp) + }).timeout(10000) + + it('server should prioritize updated_at_timestamp over updated_at for sync, if provided', async function () { + /** + * As part of SSRB to SSJS migration, server should prefer to use updated_at_timestamp + * over updated_at for sync conflict logic. The timestamps are more accurate and support + * microsecond precision, versus date objects which only go up to milliseconds. + */ + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + /** + * Create a modified payload that has updated_at set to old value, but updated_at_timestamp + * set to new value. Then send to server. If the server conflicts, it means it's incorrectly ignoring + * updated_at_timestamp and looking at updated_at. + */ + const modified = note.payload.copy({ + updated_at: new Date(0), + content: { + ...note.content, + title: Math.random(), + }, + dirty: true, + }) + await this.application.itemManager.emitItemFromPayload(modified) + await this.application.sync.sync() + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + await this.sharedFinalAssertions() + }) + + it('conflict should be created if updated_at_timestamp is not exactly equal to servers', async function () { + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + + /** First modify the item without saving so that + * our local contents digress from the server's */ + await this.application.mutator.changeItem(note, (mutator) => { + mutator.title = `${Math.random()}` + }) + const modified = note.payload.copy({ + updated_at_timestamp: note.payload.updated_at_timestamp - 1, + content: { + ...note.content, + title: Math.random(), + }, + dirty: true, + }) + this.expectedItemCount++ + await this.application.itemManager.emitItemFromPayload(modified) + await this.application.sync.sync() + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2) + await this.sharedFinalAssertions() + }) + + it('conflicting should not over resolve', async function () { + /** + * Before refactoring to use dirtyIndex instead of dirtiedDate, sometimes an item could be dirtied + * and begin sync at the exact same millisecond count (at least in the tests). In which case, the item + * would be stillDirty after sync and would sync again. This test ensures that an item is only synced once + * after it saves changes from conflicted items. + */ + const contextA = this.context + const contextB = await Factory.createAppContextWithFakeCrypto('AppB', contextA.email, contextA.password) + + contextA.disableIntegrityAutoHeal() + contextB.disableIntegrityAutoHeal() + + await contextB.launch() + await contextB.signIn() + + const note = await contextA.createSyncedNote() + await contextB.sync() + + await contextA.changeNoteTitleAndSync(note, 'title-A') + await contextB.changeNoteTitleAndSync(note, 'title-B') + + this.expectedItemCount += 2 + + const noteAExpectedTimestamp = contextB.findNoteByTitle('title-A').payload.updated_at_timestamp + const noteBExpectedTimestamp = contextB.findNoteByTitle('title-B').payload.updated_at_timestamp + + await contextA.sync() + + expect(contextA.findNoteByTitle('title-A').payload.updated_at_timestamp).to.equal(noteAExpectedTimestamp) + expect(contextA.findNoteByTitle('title-B').payload.updated_at_timestamp).to.equal(noteBExpectedTimestamp) + + await this.sharedFinalAssertions() + }).timeout(20000) + + it('editing original note many times after conflict on other client should only result in 2 cumulative notes', async function () { + const contextA = this.context + const contextB = await Factory.createAppContextWithFakeCrypto('AppB', contextA.email, contextA.password) + contextA.disableIntegrityAutoHeal() + contextB.disableIntegrityAutoHeal() + + await contextB.launch() + await contextB.signIn() + + const { original } = await contextA.createConflictedNotes(contextB) + this.expectedItemCount += 2 + + expect(contextA.noteCount).to.equal(2) + expect(contextB.noteCount).to.equal(2) + + const allSyncs = [] + + await contextA.changeNoteTitle(original, `${Math.random()}`) + allSyncs.push(contextA.sync()) + allSyncs.push(contextB.sync()) + + await Promise.all(allSyncs) + + expect(contextA.noteCount).to.equal(2) + expect(contextB.noteCount).to.equal(2) + + await this.sharedFinalAssertions() + }).timeout(20000) +}) diff --git a/packages/snjs/mocha/sync_tests/integrity.test.js b/packages/snjs/mocha/sync_tests/integrity.test.js new file mode 100644 index 000000000..c43ad5ffb --- /dev/null +++ b/packages/snjs/mocha/sync_tests/integrity.test.js @@ -0,0 +1,80 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('sync integrity', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + before(function () { + localStorage.clear() + }) + + after(function () { + localStorage.clear() + }) + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + }) + + const awaitSyncEventPromise = (application, targetEvent) => { + return new Promise((resolve) => { + application.syncService.addEventObserver((event) => { + if (event === targetEvent) { + resolve() + } + }) + }) + } + + afterEach(async function () { + expect(this.application.syncService.isOutOfSync()).to.equal(false) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + await Factory.safeDeinit(this.application) + }) + + it('should detect when out of sync', async function () { + const item = await this.application.itemManager.emitItemFromPayload( + Factory.createNotePayload(), + PayloadEmitSource.LocalChanged, + ) + this.expectedItemCount++ + + const didEnterOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.EnterOutOfSync) + await this.application.syncService.sync({ checkIntegrity: true }) + + await this.application.itemManager.removeItemLocally(item) + await this.application.syncService.sync({ checkIntegrity: true, awaitAll: true }) + + await didEnterOutOfSync + }) + + it('should self heal after out of sync', async function () { + const item = await this.application.itemManager.emitItemFromPayload( + Factory.createNotePayload(), + PayloadEmitSource.LocalChanged, + ) + this.expectedItemCount++ + + const didEnterOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.EnterOutOfSync) + const didExitOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.ExitOutOfSync) + + await this.application.syncService.sync({ checkIntegrity: true }) + await this.application.itemManager.removeItemLocally(item) + await this.application.syncService.sync({ checkIntegrity: true, awaitAll: true }) + + await Promise.all([didEnterOutOfSync, didExitOutOfSync]) + expect(this.application.syncService.isOutOfSync()).to.equal(false) + }) +}) diff --git a/packages/snjs/mocha/sync_tests/notes_tags.test.js b/packages/snjs/mocha/sync_tests/notes_tags.test.js new file mode 100644 index 000000000..f1219a749 --- /dev/null +++ b/packages/snjs/mocha/sync_tests/notes_tags.test.js @@ -0,0 +1,168 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import { createRelatedNoteTagPairPayload } from '../lib/Items.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('notes + tags syncing', function () { + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + after(function () { + localStorage.clear() + }) + + beforeEach(async function () { + this.application = await Factory.createInitAppWithFakeCrypto() + Factory.disableIntegrityAutoHeal(this.application) + const email = UuidGenerator.GenerateUuid() + const password = UuidGenerator.GenerateUuid() + await Factory.registerUserToApplication({ + application: this.application, + email, + password, + }) + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + }) + + it('syncing an item then downloading it should include items_key_id', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + await this.application.payloadManager.resetState() + await this.application.itemManager.resetState() + await this.application.syncService.clearSyncPositionTokens() + await this.application.syncService.sync(syncOptions) + const downloadedNote = this.application.itemManager.getDisplayableNotes()[0] + expect(downloadedNote.items_key_id).to.not.be.ok + // Allow time for waitingForKey + await Factory.sleep(0.1) + expect(downloadedNote.title).to.be.ok + expect(downloadedNote.content.text).to.be.ok + }) + + it('syncing a note many times does not cause duplication', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + const note = this.application.itemManager.getItems([ContentType.Note])[0] + const tag = this.application.itemManager.getItems([ContentType.Tag])[0] + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) + + for (let i = 0; i < 9; i++) { + await this.application.itemManager.setItemsDirty([note, tag]) + await this.application.syncService.sync(syncOptions) + this.application.syncService.clearSyncPositionTokens() + expect(tag.content.references.length).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) + expect(tag.noteCount).to.equal(1) + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) + console.warn('Waiting 0.1s...') + await Factory.sleep(0.1) + } + }).timeout(20000) + + it('handles signing in and merging data', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + const originalNote = this.application.itemManager.getDisplayableNotes()[0] + const originalTag = this.application.itemManager.getDisplayableTags()[0] + await this.application.itemManager.setItemsDirty([originalNote, originalTag]) + + await this.application.syncService.sync(syncOptions) + + expect(originalTag.content.references.length).to.equal(1) + expect(originalTag.noteCount).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(originalNote).length).to.equal(1) + + // when signing in, all local items are cleared from storage (but kept in memory; to clear desktop logs), + // then resaved with alternated uuids. + await this.application.diskStorageService.clearAllPayloads() + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) + + const note = this.application.itemManager.getDisplayableNotes()[0] + const tag = this.application.itemManager.getDisplayableTags()[0] + + expect(tag.content.references.length).to.equal(1) + expect(note.content.references.length).to.equal(0) + + expect(tag.noteCount).to.equal(1) + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) + }) + + it('duplicating a tag should maintian its relationships', async function () { + const pair = createRelatedNoteTagPairPayload() + const notePayload = pair[0] + const tagPayload = pair[1] + await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged) + let note = this.application.itemManager.getDisplayableNotes()[0] + let tag = this.application.itemManager.getDisplayableTags()[0] + expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1) + + await this.application.itemManager.setItemsDirty([note, tag]) + await this.application.syncService.sync(syncOptions) + await this.application.syncService.clearSyncPositionTokens() + + note = this.application.itemManager.findItem(note.uuid) + tag = this.application.itemManager.findItem(tag.uuid) + + expect(note.dirty).to.equal(false) + expect(tag.dirty).to.equal(false) + + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(1) + + await Factory.changePayloadTimeStampAndSync( + this.application, + tag.payload, + Factory.dateToMicroseconds(Factory.yesterday()), + { + title: `${Math.random()}`, + }, + syncOptions, + ) + + tag = this.application.itemManager.findItem(tag.uuid) + + // tag should now be conflicted and a copy created + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.itemManager.getDisplayableTags().length).to.equal(2) + + const tags = this.application.itemManager.getDisplayableTags() + const conflictedTag = tags.find((tag) => { + return !!tag.content.conflict_of + }) + const originalTag = tags.find((tag) => { + return tag !== conflictedTag + }) + + expect(conflictedTag.uuid).to.not.equal(originalTag.uuid) + + expect(originalTag.uuid).to.equal(tag.uuid) + expect(conflictedTag.content.conflict_of).to.equal(originalTag.uuid) + expect(conflictedTag.noteCount).to.equal(originalTag.noteCount) + + expect(this.application.itemManager.itemsReferencingItem(conflictedTag).length).to.equal(0) + expect(this.application.itemManager.itemsReferencingItem(originalTag).length).to.equal(0) + + // Two tags now link to this note + const referencingItems = this.application.itemManager.itemsReferencingItem(note) + expect(referencingItems.length).to.equal(2) + expect(referencingItems[0]).to.not.equal(referencingItems[1]) + }).timeout(10000) +}) diff --git a/packages/snjs/mocha/sync_tests/offline.test.js b/packages/snjs/mocha/sync_tests/offline.test.js new file mode 100644 index 000000000..e5cabe7b9 --- /dev/null +++ b/packages/snjs/mocha/sync_tests/offline.test.js @@ -0,0 +1,91 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('offline syncing', () => { + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + this.expectedItemCount = BASE_ITEM_COUNT + this.application = await Factory.createInitAppWithFakeCrypto() + }) + + afterEach(async function () { + expect(this.application.syncService.isOutOfSync()).to.equal(false) + await Factory.safeDeinit(this.application) + }) + + before(async function () { + localStorage.clear() + }) + + after(async function () { + localStorage.clear() + }) + + it('should sync item with no passcode', async function () { + let note = await Factory.createMappedNote(this.application) + expect(this.application.itemManager.getDirtyItems().length).to.equal(1) + + const rawPayloads1 = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads1.length).to.equal(this.expectedItemCount) + + await this.application.syncService.sync(syncOptions) + + note = this.application.items.findItem(note.uuid) + + /** In rare cases a sync can complete so fast that the dates are equal; this is ok. */ + expect(note.lastSyncEnd).to.be.at.least(note.lastSyncBegan) + + this.expectedItemCount++ + + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + + const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads2.length).to.equal(this.expectedItemCount) + + const itemsKeyRaw = (await Factory.getStoragePayloadsOfType(this.application, ContentType.ItemsKey))[0] + const noteRaw = (await Factory.getStoragePayloadsOfType(this.application, ContentType.Note))[0] + + /** Encrypts with default items key */ + expect(typeof noteRaw.content).to.equal('string') + + /** Not encrypted as no passcode/root key */ + expect(typeof itemsKeyRaw.content).to.equal('object') + }) + + it('should sync item encrypted with passcode', async function () { + await this.application.addPasscode('foobar') + await Factory.createMappedNote(this.application) + expect(this.application.itemManager.getDirtyItems().length).to.equal(1) + const rawPayloads1 = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads1.length).to.equal(this.expectedItemCount) + + await this.application.syncService.sync(syncOptions) + this.expectedItemCount++ + + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads2.length).to.equal(this.expectedItemCount) + + const payload = rawPayloads2[0] + expect(typeof payload.content).to.equal('string') + expect(payload.content.startsWith(this.application.protocolService.getLatestVersion())).to.equal(true) + }) + + it('signing out while offline should succeed', async function () { + await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await this.application.syncService.sync(syncOptions) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + expect(this.application.noAccount()).to.equal(true) + expect(this.application.getUser()).to.not.be.ok + }) +}) diff --git a/packages/snjs/mocha/sync_tests/online.test.js b/packages/snjs/mocha/sync_tests/online.test.js new file mode 100644 index 000000000..371990676 --- /dev/null +++ b/packages/snjs/mocha/sync_tests/online.test.js @@ -0,0 +1,1052 @@ +/* eslint-disable no-undef */ +import * as Factory from '../lib/factory.js' +import * as Utils from '../lib/Utils.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('online syncing', function () { + this.timeout(Factory.TenSecondTimeout) + const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */ + + const syncOptions = { + checkIntegrity: true, + awaitAll: true, + } + + beforeEach(async function () { + localStorage.clear() + this.expectedItemCount = BASE_ITEM_COUNT + + this.context = await Factory.createAppContext() + await this.context.launch() + + this.application = this.context.application + this.email = this.context.email + this.password = this.context.password + + Factory.disableIntegrityAutoHeal(this.application) + + await Factory.registerUserToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + this.signOut = async () => { + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + } + + this.signIn = async () => { + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + } + }) + + afterEach(async function () { + expect(this.application.syncService.isOutOfSync()).to.equal(false) + const items = this.application.itemManager.allTrackedItems() + expect(items.length).to.equal(this.expectedItemCount) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + function noteObjectsFromObjects(items) { + return items.filter((item) => item.content_type === ContentType.Note) + } + + it('should register and sync basic model online', async function () { + let note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + note = this.application.items.findItem(note.uuid) + expect(note.dirty).to.not.be.ok + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const notePayloads = noteObjectsFromObjects(rawPayloads) + expect(notePayloads.length).to.equal(1) + for (const rawNote of notePayloads) { + expect(rawNote.dirty).to.not.be.ok + } + }) + + it('should login and retrieve synced item', async function () { + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + const notes = this.application.itemManager.getDisplayableNotes() + expect(notes.length).to.equal(1) + expect(notes[0].title).to.equal(note.title) + }) + + it('can complete multipage sync on sign in', async function () { + const count = 0 + + await Factory.createManyMappedNotes(this.application, count) + + this.expectedItemCount += count + + await this.application.sync.sync(syncOptions) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + + expect(this.application.itemManager.items.length).to.equal(BASE_ITEM_COUNT) + + const promise = Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + /** Throw in some random syncs to cause trouble */ + const syncCount = 30 + + for (let i = 0; i < syncCount; i++) { + this.application.sync.sync(syncOptions) + await Factory.sleep(0.01) + } + await promise + expect(promise).to.be.fulfilled + + /** Allow any unwaited syncs in for loop to complete */ + await Factory.sleep(0.5) + }).timeout(20000) + + it('uuid alternation should delete original payload', async function () { + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await Factory.alternateUuidForItem(this.application, note.uuid) + await this.application.sync.sync(syncOptions) + + const notes = this.application.itemManager.getDisplayableNotes() + expect(notes.length).to.equal(1) + expect(notes[0].uuid).to.not.equal(note.uuid) + }) + + it('having offline data then signing in should not alternate uuid and merge with account', async function () { + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + mergeLocal: true, + }) + + const notes = this.application.itemManager.getDisplayableNotes() + expect(notes.length).to.equal(1) + /** uuid should have been alternated */ + expect(notes[0].uuid).to.equal(note.uuid) + }) + + it('resolve on next timing strategy', async function () { + const syncCount = 7 + let successes = 0 + let events = 0 + + this.application.syncService.ut_beginLatencySimulator(250) + this.application.syncService.addEventObserver((event, data) => { + if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) { + events++ + } + }) + + const promises = [] + for (let i = 0; i < syncCount; i++) { + promises.push( + this.application.syncService + .sync({ + queueStrategy: SyncQueueStrategy.ResolveOnNext, + }) + .then(() => { + successes++ + }), + ) + } + + await Promise.all(promises) + expect(successes).to.equal(syncCount) + // Only a fully executed sync request creates a sync:completed event. + // We don't know how many will execute above. + expect(events).to.be.at.least(1) + + this.application.syncService.ut_endLatencySimulator() + // Since the syncs all happen after one another, extra syncs may be queued on that we are not awaiting. + await Factory.sleep(0.5) + }) + + it('force spawn new timing strategy', async function () { + const syncCount = 7 + let successes = 0 + let events = 0 + + this.application.syncService.ut_beginLatencySimulator(250) + + this.application.syncService.addEventObserver((event, data) => { + if (event === SyncEvent.SyncCompletedWithAllItemsUploaded) { + events++ + } + }) + + const promises = [] + for (let i = 0; i < syncCount; i++) { + promises.push( + this.application.syncService + .sync({ + queueStrategy: SyncQueueStrategy.ForceSpawnNew, + }) + .then(() => { + successes++ + }), + ) + } + await Promise.all(promises) + expect(successes).to.equal(syncCount) + expect(events).to.equal(syncCount) + this.application.syncService.ut_endLatencySimulator() + }) + + it('retrieving new items should not mark them as dirty', async function () { + const originalNote = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + this.application.syncService.addEventObserver((event) => { + if (event === SyncEvent.SingleRoundTripSyncCompleted) { + const note = this.application.items.findItem(originalNote.uuid) + expect(note.dirty).to.not.be.ok + } + }) + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + }) + + it('allows saving of data after sign out', async function () { + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + const note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const notePayload = noteObjectsFromObjects(rawPayloads) + expect(notePayload.length).to.equal(1) + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + + // set item to be merged for when sign in occurs + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + expect(this.application.syncService.isOutOfSync()).to.equal(false) + expect(this.application.itemManager.getDirtyItems().length).to.equal(BASE_ITEM_COUNT + 1) + + // Sign back in for next tests + await Factory.loginToApplication({ + application: this.application, + email: this.email, + password: this.password, + }) + + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + expect(this.application.syncService.isOutOfSync()).to.equal(false) + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + + for (const item of this.application.itemManager.getDisplayableNotes()) { + expect(item.content.title).to.be.ok + } + + const updatedRawPayloads = await this.application.diskStorageService.getAllRawPayloads() + for (const payload of updatedRawPayloads) { + // if an item comes back from the server, it is saved to disk immediately without a dirty value. + expect(payload.dirty).to.not.be.ok + } + }) + + it('mapping should not mutate items with error decrypting state', async function () { + const note = await Factory.createMappedNote(this.application) + + this.expectedItemCount++ + + const originalTitle = note.content.title + + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + const encrypted = CreateEncryptedServerSyncPushPayload( + await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note.payloadRepresentation()], + }, + }), + ) + + const errorred = new EncryptedPayload({ + ...encrypted, + errorDecrypting: true, + }) + + const items = await this.application.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged) + + const mappedItem = this.application.itemManager.findAnyItem(errorred.uuid) + + expect(typeof mappedItem.content).to.equal('string') + + const decryptedPayload = await this.application.protocolService.decryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [errorred], + }, + }) + + const mappedItems2 = await this.application.itemManager.emitItemsFromPayloads( + [decryptedPayload], + PayloadEmitSource.LocalChanged, + ) + + const mappedItem2 = mappedItems2[0] + expect(typeof mappedItem2.content).to.equal('object') + expect(mappedItem2.content.title).to.equal(originalTitle) + }) + + it('signing into account with pre-existing items', async function () { + const note = await Factory.createMappedNote(this.application) + await Factory.markDirtyAndSyncItem(this.application, note) + this.expectedItemCount += 1 + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('removes item from storage upon deletion', async function () { + let note = await Factory.createMappedNote(this.application) + this.expectedItemCount++ + + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + note = this.application.items.findItem(note.uuid) + expect(note.dirty).to.equal(false) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + await this.application.itemManager.setItemToBeDeleted(note) + note = this.application.items.findAnyItem(note.uuid) + expect(note.dirty).to.equal(true) + this.expectedItemCount-- + + await this.application.syncService.sync(syncOptions) + note = this.application.items.findItem(note.uuid) + expect(note).to.not.be.ok + + // We expect that this item is now gone for good, and no duplicate has been created. + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + await Factory.sleep(0.5) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + }) + + it('retrieving item with no content should correctly map local state', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + + const syncToken = await this.application.syncService.getLastSyncToken() + + this.expectedItemCount++ + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + // client A + await this.application.itemManager.setItemToBeDeleted(note) + await this.application.syncService.sync(syncOptions) + + // Subtract 1 + this.expectedItemCount-- + + // client B + // Clearing sync tokens wont work as server wont return deleted items. + // Set saved sync token instead + await this.application.syncService.setLastSyncToken(syncToken) + await this.application.syncService.sync(syncOptions) + + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('changing an item while it is being synced should sync again', async function () { + const note = await Factory.createMappedNote(this.application) + + this.expectedItemCount++ + + /** Begin syncing it with server but introduce latency so we can sneak in a delete */ + this.application.syncService.ut_beginLatencySimulator(500) + + const sync = this.application.sync.sync() + + /** Sleep so sync call can begin preparations but not fully begin */ + + await Factory.sleep(0.1) + + await this.application.itemManager.changeItem(note, (mutator) => { + mutator.title = 'latest title' + }) + + await sync + + this.application.syncService.ut_endLatencySimulator() + + await this.application.sync.sync(syncOptions) + + const latestNote = this.application.itemManager.findItem(note.uuid) + expect(latestNote.title).to.equal('latest title') + }) + + it('deleting an item while it is being synced should keep deletion state', async function () { + const note = await Factory.createMappedNote(this.application) + + this.expectedItemCount++ + + /** Begin syncing it with server but introduce latency so we can sneak in a delete */ + this.application.syncService.ut_beginLatencySimulator(500) + + const sync = this.application.sync.sync() + + /** Sleep so sync call can begin preparations but not fully begin */ + + await Factory.sleep(0.1) + + await this.application.itemManager.setItemToBeDeleted(note) + + this.expectedItemCount-- + + await sync + + this.application.syncService.ut_endLatencySimulator() + + await this.application.sync.sync(syncOptions) + + /** We expect that item has been deleted */ + const allItems = this.application.itemManager.items + expect(allItems.length).to.equal(this.expectedItemCount) + }) + + it('items that are never synced and deleted should not be uploaded to server', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + await this.application.itemManager.setItemToBeDeleted(note) + + let success = true + let didCompleteRelevantSync = false + let beginCheckingResponse = false + this.application.syncService.addEventObserver((eventName, data) => { + if (eventName === SyncEvent.DownloadFirstSyncCompleted) { + beginCheckingResponse = true + } + if (!beginCheckingResponse) { + return + } + if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + didCompleteRelevantSync = true + const response = data + const matching = response.savedPayloads.find((p) => p.uuid === note.uuid) + if (matching) { + success = false + } + } + }) + await this.application.syncService.sync({ mode: SyncMode.DownloadFirst }) + expect(didCompleteRelevantSync).to.equal(true) + expect(success).to.equal(true) + }) + + it('items that are deleted after download first sync complete should not be uploaded to server', async function () { + /** The singleton manager may delete items are download first. We dont want those uploaded to server. */ + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + + let success = true + let didCompleteRelevantSync = false + let beginCheckingResponse = false + this.application.syncService.addEventObserver(async (eventName, data) => { + if (eventName === SyncEvent.DownloadFirstSyncCompleted) { + await this.application.itemManager.setItemToBeDeleted(note) + beginCheckingResponse = true + } + if (!beginCheckingResponse) { + return + } + if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) { + didCompleteRelevantSync = true + const response = data + const matching = response.savedPayloads.find((p) => p.uuid === note.uuid) + if (matching) { + success = false + } + } + }) + await this.application.syncService.sync({ mode: SyncMode.DownloadFirst }) + expect(didCompleteRelevantSync).to.equal(true) + expect(success).to.equal(true) + }) + + it('marking an item dirty then saving to disk should retain that dirty state when restored', async function () { + const note = await Factory.createMappedNote(this.application) + + this.expectedItemCount++ + + await this.application.syncService.markAllItemsAsNeedingSyncAndPersist() + + this.application.itemManager.resetState() + this.application.payloadManager.resetState() + + await this.application.syncService.clearSyncPositionTokens() + + expect(this.application.itemManager.items.length).to.equal(0) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + + const encryptedPayloads = rawPayloads.map((rawPayload) => { + return new EncryptedPayload(rawPayload) + }) + + const encryptionSplit = SplitPayloadsByEncryptionType(encryptedPayloads) + + const keyedSplit = CreateDecryptionSplitWithKeyLookup(encryptionSplit) + + const decryptionResults = await this.application.protocolService.decryptSplit(keyedSplit) + + await this.application.itemManager.emitItemsFromPayloads(decryptionResults, PayloadEmitSource.LocalChanged) + + expect(this.application.itemManager.allTrackedItems().length).to.equal(this.expectedItemCount) + + const foundNote = this.application.itemManager.findAnyItem(note.uuid) + + expect(foundNote.dirty).to.equal(true) + + await this.application.syncService.sync(syncOptions) + }) + + /** Temporarily skipping due to long run time */ + it.skip('should handle uploading with sync pagination', async function () { + const largeItemCount = SyncUpDownLimit + 10 + for (let i = 0; i < largeItemCount; i++) { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + } + + this.expectedItemCount += largeItemCount + + await this.application.syncService.sync(syncOptions) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + }).timeout(15000) + + /** Temporarily skipping due to long run time */ + it.skip('should handle downloading with sync pagination', async function () { + const largeItemCount = SyncUpDownLimit + 10 + for (let i = 0; i < largeItemCount; i++) { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + } + /** Upload */ + this.application.syncService.sync({ awaitAll: true, checkIntegrity: false }) + await this.context.awaitNextSucessfulSync() + this.expectedItemCount += largeItemCount + + /** Clear local data */ + await this.application.payloadManager.resetState() + await this.application.itemManager.resetState() + await this.application.syncService.clearSyncPositionTokens() + await this.application.diskStorageService.clearAllPayloads() + expect(this.application.itemManager.items.length).to.equal(0) + + /** Download all data */ + this.application.syncService.sync(syncOptions) + await this.context.awaitNextSucessfulSync() + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + }).timeout(30000) + + it('syncing an item should storage it encrypted', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + await this.application.syncService.sync(syncOptions) + this.expectedItemCount++ + const rawPayloads = await this.application.syncService.getDatabasePayloads() + const notePayload = rawPayloads.find((p) => p.content_type === ContentType.Note) + expect(typeof notePayload.content).to.equal('string') + }) + + it('syncing an item before data load should storage it encrypted', async function () { + const note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + /** Simulate database not loaded */ + await this.application.syncService.clearSyncPositionTokens() + this.application.syncService.ut_setDatabaseLoaded(false) + this.application.syncService.sync(syncOptions) + await Factory.sleep(0.3) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const notePayload = rawPayloads.find((p) => p.content_type === ContentType.Note) + expect(typeof notePayload.content).to.equal('string') + }) + + it('saving an item after sync should persist it with content property', async function () { + const note = await Factory.createMappedNote(this.application) + const text = Factory.randomString(10000) + await this.application.mutator.changeAndSaveItem( + note, + (mutator) => { + mutator.text = text + }, + undefined, + undefined, + syncOptions, + ) + this.expectedItemCount++ + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + const notePayload = rawPayloads.find((p) => p.content_type === ContentType.Note) + expect(typeof notePayload.content).to.equal('string') + expect(notePayload.content.length).to.be.above(text.length) + }) + + it('syncing a new item before local data has loaded should still persist the item to disk', async function () { + this.application.syncService.ut_setDatabaseLoaded(false) + /** You don't want to clear model manager state as we'll lose encrypting items key */ + // await this.application.payloadManager.resetState(); + await this.application.syncService.clearSyncPositionTokens() + expect(this.application.itemManager.getDirtyItems().length).to.equal(0) + + let note = await Factory.createMappedNote(this.application) + note = await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = `${Math.random()}` + }) + /** This sync request should exit prematurely as we called ut_setDatabaseNotLoaded */ + /** Do not await. Sleep instead. */ + this.application.syncService.sync(syncOptions) + await Factory.sleep(0.3) + this.expectedItemCount++ + + /** Item should still be dirty */ + expect(note.dirty).to.equal(true) + expect(this.application.itemManager.getDirtyItems().length).to.equal(1) + + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(this.expectedItemCount) + const rawPayload = rawPayloads.find((p) => p.uuid === note.uuid) + expect(rawPayload.uuid).to.equal(note.uuid) + expect(rawPayload.dirty).equal(true) + expect(typeof rawPayload.content).to.equal('string') + + /** Clear state data and upload item from storage to server */ + await this.application.syncService.clearSyncPositionTokens() + await this.application.payloadManager.resetState() + await this.application.itemManager.resetState() + const databasePayloads = await this.application.diskStorageService.getAllRawPayloads() + await this.application.syncService.loadDatabasePayloads(databasePayloads) + await this.application.syncService.sync(syncOptions) + + const newRawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(newRawPayloads.length).to.equal(this.expectedItemCount) + + const currentItem = this.application.itemManager.findItem(note.uuid) + expect(currentItem.content.text).to.equal(note.content.text) + expect(currentItem.text).to.equal(note.text) + expect(currentItem.dirty).to.not.be.ok + }) + + it('load local items should respect sort priority', function () { + const contentTypes = ['A', 'B', 'C'] + const itemCount = 6 + const originalPayloads = [] + for (let i = 0; i < itemCount; i++) { + const payload = Factory.createStorageItemPayload(contentTypes[Math.floor(i / 2)]) + originalPayloads.push(payload) + } + const sorted = SortPayloadsByRecentAndContentPriority(originalPayloads, ['C', 'A', 'B']) + expect(sorted[0].content_type).to.equal('C') + expect(sorted[2].content_type).to.equal('A') + expect(sorted[4].content_type).to.equal('B') + }) + + it('should sign in and retrieve large number of items', async function () { + const largeItemCount = 50 + await Factory.createManyMappedNotes(this.application, largeItemCount) + this.expectedItemCount += largeItemCount + await this.application.syncService.sync(syncOptions) + + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + const rawPayloads = await this.application.diskStorageService.getAllRawPayloads() + expect(rawPayloads.length).to.equal(BASE_ITEM_COUNT) + + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + + this.application.syncService.ut_setDatabaseLoaded(false) + const databasePayloads = await this.application.diskStorageService.getAllRawPayloads() + await this.application.syncService.loadDatabasePayloads(databasePayloads) + await this.application.syncService.sync(syncOptions) + + const items = await this.application.itemManager.items + expect(items.length).to.equal(this.expectedItemCount) + }).timeout(20000) + + it('valid sync date tracking', async function () { + let note = await Factory.createMappedNote(this.application) + note = await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + expect(note.dirty).to.equal(true) + expect(note.payload.dirtyIndex).to.be.at.most(getCurrentDirtyIndex()) + + note = await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = `${Math.random()}` + }) + const sync = this.application.sync.sync(syncOptions) + await Factory.sleep(0.1) + note = this.application.items.findItem(note.uuid) + expect(note.lastSyncBegan).to.be.below(new Date()) + await sync + note = this.application.items.findItem(note.uuid) + expect(note.dirty).to.equal(false) + expect(note.lastSyncEnd).to.be.at.least(note.lastSyncBegan) + }) + + it('syncing twice without waiting should only execute 1 online sync', async function () { + const expectedEvents = 1 + let actualEvents = 0 + this.application.syncService.addEventObserver((event, data) => { + if (event === SyncEvent.SyncCompletedWithAllItemsUploaded && data.source === SyncSource.External) { + actualEvents++ + } + }) + const first = this.application.sync.sync() + const second = this.application.sync.sync() + await Promise.all([first, second]) + /** Sleep so that any automatic syncs that are triggered are also sent to handler above */ + await Factory.sleep(0.5) + expect(actualEvents).to.equal(expectedEvents) + }) + + it('should keep an item dirty thats been modified after low latency sync request began', async function () { + /** + * If you begin a sync request that takes 20s to complete, then begin modifying an item + * many times and attempt to sync, it will await the initial sync to complete. + * When that completes, it will decide whether an item is still dirty or not. + * It will do based on comparing whether item.dirtyIndex > item.globalDirtyIndexAtLastSync + */ + let note = await Factory.createMappedNote(this.application) + await this.application.itemManager.setItemDirty(note) + this.expectedItemCount++ + + // client A. Don't await, we want to do other stuff. + this.application.syncService.ut_beginLatencySimulator(1500) + const slowSync = this.application.syncService.sync(syncOptions) + await Factory.sleep(0.1) + expect(note.dirty).to.equal(true) + + // While that sync is going on, we want to modify this item many times. + const text = `${Math.random()}` + note = await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = text + }) + await this.application.itemManager.setItemDirty(note) + await this.application.itemManager.setItemDirty(note) + await this.application.itemManager.setItemDirty(note) + expect(note.payload.dirtyIndex).to.be.above(note.payload.globalDirtyIndexAtLastSync) + + // Now do a regular sync with no latency. + this.application.syncService.ut_endLatencySimulator() + const midSync = this.application.syncService.sync(syncOptions) + + await slowSync + await midSync + + note = this.application.items.findItem(note.uuid) + expect(note.dirty).to.equal(false) + expect(note.lastSyncEnd).to.be.above(note.lastSyncBegan) + expect(note.content.text).to.equal(text) + + // client B + await this.application.payloadManager.resetState() + await this.application.itemManager.resetState() + await this.application.syncService.clearSyncPositionTokens() + await this.application.syncService.sync(syncOptions) + + // Expect that the server value and client value match, and no conflicts are created. + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + const foundItem = this.application.itemManager.findItem(note.uuid) + expect(foundItem.content.text).to.equal(text) + expect(foundItem.text).to.equal(text) + }) + + it('should sync an item twice if its marked dirty while a sync is ongoing', async function () { + /** We can't track how many times an item is synced, only how many times its mapped */ + const expectedSaveCount = 2 + let actualSaveCount = 0 + + /** Create an item and sync it */ + let note = await Factory.createMappedNote(this.application) + + this.application.itemManager.addObserver(ContentType.Note, ({ source }) => { + if (source === PayloadEmitSource.RemoteSaved) { + actualSaveCount++ + } + }) + + this.expectedItemCount++ + this.application.syncService.ut_beginLatencySimulator(150) + + /** Dont await */ + const syncRequest = this.application.syncService.sync(syncOptions) + + /** Dirty the item 100ms into 150ms request */ + const newText = `${Math.random()}` + + setTimeout( + async function () { + await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = newText + }) + }.bind(this), + 100, + ) + + /** + * Await sync request. A sync request will perform another request if there + * are still more dirty items, so awaiting this will perform two syncs. + */ + await syncRequest + expect(actualSaveCount).to.equal(expectedSaveCount) + note = this.application.items.findItem(note.uuid) + expect(note.text).to.equal(newText) + }) + + it('marking item dirty after dirty items are prepared for sync but before they are synced should sync again', async function () { + /** + * There is a twilight zone where items needing sync are popped, and then say about 100ms of processing before + * we set those items' lastSyncBegan. If the item is dirtied in between these times, then item.dirtyIndex will be less than + * item.globalDirtyIndexAtLastSync, and it will not by synced again. + */ + + const expectedSaveCount = 2 + let actualSaveCount = 0 + + /** Create an item and sync it */ + let note = await Factory.createMappedNote(this.application) + + this.application.itemManager.addObserver(ContentType.Note, ({ source }) => { + if (source === PayloadEmitSource.RemoteSaved) { + actualSaveCount++ + } + }) + this.expectedItemCount++ + + /** Dont await */ + const syncRequest = this.application.syncService.sync(syncOptions) + + /** Dirty the item before lastSyncBegan is set */ + let didPerformMutatation = false + const newText = `${Math.random()}` + + this.application.syncService.addEventObserver(async (eventName) => { + if (eventName === SyncEvent.SyncWillBegin && !didPerformMutatation) { + didPerformMutatation = true + await this.application.itemManager.changeItem(note, (mutator) => { + mutator.text = newText + }) + } + }) + + await syncRequest + + expect(actualSaveCount).to.equal(expectedSaveCount) + note = this.application.items.findItem(note.uuid) + expect(note.text).to.equal(newText) + }) + + it('marking item dirty during presync save should sync again', async function () { + const expectedSaveCount = 2 + let actualSaveCount = 0 + + /** Create an item and sync it */ + let note = await Factory.createMappedNote(this.application) + let didPerformMutatation = false + const newText = `${Math.random()}` + + this.application.itemManager.addObserver(ContentType.Note, async ({ changed, source }) => { + if (source === PayloadEmitSource.RemoteSaved) { + actualSaveCount++ + } else if (source === PayloadEmitSource.PreSyncSave && !didPerformMutatation) { + didPerformMutatation = true + + const mutated = changed[0].payload.copy({ + content: { ...note.payload.content, text: newText }, + dirty: true, + dirtyIndex: changed[0].payload.globalDirtyIndexAtLastSync + 1, + }) + + await this.application.itemManager.emitItemFromPayload(mutated) + } + }) + + this.expectedItemCount++ + + /** Dont await */ + const syncRequest = this.application.syncService.sync(syncOptions) + await syncRequest + expect(actualSaveCount).to.equal(expectedSaveCount) + note = this.application.items.findItem(note.uuid) + expect(note.text).to.equal(newText) + }) + + it('retreiving a remote deleted item should succeed', async function () { + const note = await Factory.createSyncedNote(this.application) + const preDeleteSyncToken = await this.application.syncService.getLastSyncToken() + await this.application.mutator.deleteItem(note) + await this.application.syncService.setLastSyncToken(preDeleteSyncToken) + await this.application.sync.sync(syncOptions) + expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount) + }) + + it('errored items should not be synced', async function () { + const note = await Factory.createSyncedNote(this.application) + this.expectedItemCount++ + const lastSyncBegan = note.lastSyncBegan + const lastSyncEnd = note.lastSyncEnd + + const encrypted = await this.application.protocolService.encryptSplitSingle({ + usesItemsKeyWithKeyLookup: { + items: [note.payload], + }, + }) + + const errored = encrypted.copy({ + errorDecrypting: true, + dirty: true, + }) + + await this.application.itemManager.emitItemFromPayload(errored) + await this.application.sync.sync(syncOptions) + + const updatedNote = this.application.items.findAnyItem(note.uuid) + expect(updatedNote.lastSyncBegan.getTime()).to.equal(lastSyncBegan.getTime()) + expect(updatedNote.lastSyncEnd.getTime()).to.equal(lastSyncEnd.getTime()) + }) + + it('should not allow receiving decrypted payloads from server', async function () { + const invalidPayload = new DecryptedPayload( + { ...Factory.createNotePayload(), uuid: 'rejected' }, + PayloadSource.RemoteRetrieved, + ) + + const validPayload = new EncryptedPayload({ + uuid: '123', + content_type: 'Note', + content: '004:...', + }) + + this.expectedItemCount++ + + const response = new ServerSyncResponse({ + data: { + retrieved_items: [invalidPayload.ejected(), validPayload.ejected()], + }, + }) + + await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response) + + expect(this.application.payloadManager.findOne(invalidPayload.uuid)).to.not.be.ok + expect(this.application.payloadManager.findOne(validPayload.uuid)).to.be.ok + }) + + it('retrieved items should have both updated_at and updated_at_timestamps', async function () { + const note = await Factory.createSyncedNote(this.application) + + this.expectedItemCount++ + + expect(note.payload.created_at_timestamp).to.be.ok + expect(note.payload.created_at).to.be.ok + expect(note.payload.updated_at_timestamp).to.be.ok + expect(note.payload.updated_at).to.be.ok + }) + + it('syncing an item with non-supported content type should not result in infinite loop', async function () { + /** + * When a client tries to sync an item with a server-unrecognized content type, it will + * be returned by the server as an error conflict. + */ + const payload = new DecryptedPayload({ + uuid: Utils.generateUuid(), + content_type: 'Foo', + dirty: true, + content: {}, + }) + this.expectedItemCount++ + await this.application.itemManager.emitItemsFromPayloads([payload]) + await this.application.sync.sync(syncOptions) + + /** Item should no longer be dirty, otherwise it would keep syncing */ + const item = this.application.items.findItem(payload.uuid) + expect(item.dirty).to.not.be.ok + }) + + it('should call onPresyncSave before sync begins', async function () { + const events = [] + this.application.syncService.addEventObserver((event) => { + if (event === SyncEvent.SyncWillBegin) { + events.push('sync-will-begin') + } + }) + + await this.application.syncService.sync({ + onPresyncSave: () => { + events.push('on-presync-save') + }, + }) + + expect(events[0]).to.equal('on-presync-save') + expect(events[1]).to.equal('sync-will-begin') + }) + + it('deleting an item permanently should include it in PayloadEmitSource.PreSyncSave item change observer', async function () { + let conditionMet = false + + this.application.streamItems([ContentType.Note], async ({ removed, source }) => { + if (source === PayloadEmitSource.PreSyncSave && removed.length === 1) { + conditionMet = true + } + }) + + const note = await Factory.createSyncedNote(this.application) + await this.application.mutator.deleteItem(note) + + expect(conditionMet).to.equal(true) + }) + + it('deleting a note on one client should update notes count on the other', async function () { + const contextA = this.context + const contextB = await Factory.createAppContextWithFakeCrypto('AppB', contextA.email, contextA.password) + + await contextB.launch() + await contextB.signIn() + + const note = await contextA.createSyncedNote() + await contextB.sync() + + expect(contextB.application.items.allCountableNotesCount()).to.equal(1) + + await contextA.deleteItemAndSync(note) + await contextB.sync() + + expect(contextB.application.items.allCountableNotesCount()).to.equal(0) + }) +}) diff --git a/packages/snjs/mocha/test.html b/packages/snjs/mocha/test.html new file mode 100644 index 000000000..742c78e7a --- /dev/null +++ b/packages/snjs/mocha/test.html @@ -0,0 +1,103 @@ + + + + + Mocha Tests + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/packages/snjs/mocha/upgrading.test.js b/packages/snjs/mocha/upgrading.test.js new file mode 100644 index 000000000..78cf9525a --- /dev/null +++ b/packages/snjs/mocha/upgrading.test.js @@ -0,0 +1,282 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +import * as Factory from './lib/factory.js' +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('upgrading', () => { + beforeEach(async function () { + localStorage.clear() + this.application = await Factory.createInitAppWithFakeCrypto() + this.email = UuidGenerator.GenerateUuid() + this.password = UuidGenerator.GenerateUuid() + this.passcode = '1234' + + const promptValueReply = (prompts) => { + const values = [] + for (const prompt of prompts) { + if (prompt.validation === ChallengeValidation.LocalPasscode) { + values.push(CreateChallengeValue(prompt, this.passcode)) + } else { + values.push(CreateChallengeValue(prompt, this.password)) + } + } + return values + } + this.receiveChallenge = (challenge) => { + void this.receiveChallengeWithApp(this.application, challenge) + } + this.receiveChallengeWithApp = (application, challenge) => { + application.addChallengeObserver(challenge, { + onInvalidValue: (value) => { + const values = promptValueReply([value.prompt]) + application.submitValuesForChallenge(challenge, values) + numPasscodeAttempts++ + }, + }) + const initialValues = promptValueReply(challenge.prompts) + application.submitValuesForChallenge(challenge, initialValues) + } + }) + + afterEach(async function () { + await Factory.safeDeinit(this.application) + localStorage.clear() + }) + + it('upgrade should be available when account only', async function () { + const oldVersion = ProtocolVersion.V003 + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: oldVersion, + }) + + expect(await this.application.protocolUpgradeAvailable()).to.equal(true) + }) + + it('upgrade should be available when passcode only', async function () { + const oldVersion = ProtocolVersion.V003 + await Factory.setOldVersionPasscode({ + application: this.application, + passcode: this.passcode, + version: oldVersion, + }) + + expect(await this.application.protocolUpgradeAvailable()).to.equal(true) + }) + + it('upgrades application protocol from 003 to 004', async function () { + const oldVersion = ProtocolVersion.V003 + const newVersion = ProtocolVersion.V004 + + await Factory.createMappedNote(this.application) + + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: oldVersion, + }) + + await Factory.setOldVersionPasscode({ + application: this.application, + passcode: this.passcode, + version: oldVersion, + }) + + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + oldVersion, + ) + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion) + + this.application.setLaunchCallback({ + receiveChallenge: this.receiveChallenge, + }) + const result = await this.application.upgradeProtocolVersion() + expect(result).to.deep.equal({ success: true }) + + const wrappedRootKey = await this.application.protocolService.rootKeyEncryption.getWrappedRootKey() + const payload = new EncryptedPayload(wrappedRootKey) + expect(payload.version).to.equal(newVersion) + + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + newVersion, + ) + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(newVersion) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(newVersion) + + /** + * Immediately logging out ensures we don't rely on subsequent + * sync events to complete the upgrade + */ + this.application = await Factory.signOutApplicationAndReturnNew(this.application) + await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true) + expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1) + expect(this.application.payloadManager.invalidPayloads).to.be.empty + }).timeout(15000) + + it('upgrading from 003 to 004 with passcode only then reiniting app should create valid state', async function () { + /** + * There was an issue where having the old app set up with passcode, + * then refreshing with new app, performing upgrade, then refreshing the app + * resulted in note data being errored. + */ + const oldVersion = ProtocolVersion.V003 + + await Factory.setOldVersionPasscode({ + application: this.application, + passcode: this.passcode, + version: oldVersion, + }) + await Factory.createSyncedNote(this.application) + + this.application.setLaunchCallback({ + receiveChallenge: this.receiveChallenge, + }) + + const identifier = this.application.identifier + + /** Recreate the app once */ + const appFirst = Factory.createApplicationWithFakeCrypto(identifier) + await appFirst.prepareForLaunch({ + receiveChallenge: (challenge) => { + this.receiveChallengeWithApp(appFirst, challenge) + }, + }) + await appFirst.launch(true) + const result = await appFirst.upgradeProtocolVersion() + expect(result).to.deep.equal({ success: true }) + expect(appFirst.payloadManager.invalidPayloads).to.be.empty + await Factory.safeDeinit(appFirst) + + /** Recreate the once more */ + const appSecond = Factory.createApplicationWithFakeCrypto(identifier) + await appSecond.prepareForLaunch({ + receiveChallenge: (challenge) => { + this.receiveChallengeWithApp(appSecond, challenge) + }, + }) + await appSecond.launch(true) + expect(appSecond.payloadManager.invalidPayloads).to.be.empty + await Factory.safeDeinit(appSecond) + }).timeout(15000) + + it('protocol version should be upgraded on password change', async function () { + /** Delete default items key that is created on launch */ + const itemsKey = await this.application.protocolService.getSureDefaultItemsKey() + await this.application.itemManager.setItemToBeDeleted(itemsKey) + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(0) + + Factory.createMappedNote(this.application) + + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: ProtocolVersion.V003, + }) + + expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1) + + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(ProtocolVersion.V003) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(ProtocolVersion.V003) + + /** Ensure note is encrypted with 003 */ + const notePayloads = await Factory.getStoragePayloadsOfType(this.application, ContentType.Note) + expect(notePayloads.length).to.equal(1) + expect(notePayloads[0].version).to.equal(ProtocolVersion.V003) + + const { error } = await this.application.changePassword(this.password, 'foobarfoo') + expect(error).to.not.exist + + const latestVersion = this.application.protocolService.getLatestVersion() + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(latestVersion) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(latestVersion) + + const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey() + expect(defaultItemsKey.keyVersion).to.equal(latestVersion) + + /** After change, note should now be encrypted with latest protocol version */ + + const note = this.application.itemManager.getDisplayableNotes()[0] + await Factory.markDirtyAndSyncItem(this.application, note) + + const refreshedNotePayloads = await Factory.getStoragePayloadsOfType(this.application, ContentType.Note) + const refreshedNotePayload = refreshedNotePayloads[0] + expect(refreshedNotePayload.version).to.equal(latestVersion) + }).timeout(5000) + + describe('upgrade failure', function () { + this.timeout(30000) + const oldVersion = ProtocolVersion.V003 + + beforeEach(async function () { + await Factory.createMappedNote(this.application) + + /** Register with 003 version */ + await Factory.registerOldUser({ + application: this.application, + email: this.email, + password: this.password, + version: oldVersion, + }) + + await Factory.setOldVersionPasscode({ + application: this.application, + passcode: this.passcode, + version: oldVersion, + }) + }) + + afterEach(function () { + sinon.restore() + }) + + it('rolls back the local protocol upgrade if syncing fails', async function () { + sinon.replace(this.application.syncService, 'sync', sinon.fake()) + this.application.setLaunchCallback({ + receiveChallenge: this.receiveChallenge, + }) + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + oldVersion, + ) + const errors = await this.application.upgradeProtocolVersion() + expect(errors).to.not.be.empty + + /** Ensure we're still on 003 */ + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + oldVersion, + ) + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion) + expect((await this.application.protocolService.getSureDefaultItemsKey()).keyVersion).to.equal(oldVersion) + }) + + it('rolls back the local protocol upgrade if the server responds with an error', async function () { + sinon.replace(this.application.sessionManager, 'changeCredentials', () => [Error()]) + + this.application.setLaunchCallback({ + receiveChallenge: this.receiveChallenge, + }) + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + oldVersion, + ) + const errors = await this.application.upgradeProtocolVersion() + expect(errors).to.not.be.empty + + /** Ensure we're still on 003 */ + expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal( + oldVersion, + ) + expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion) + expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion) + expect((await this.application.protocolService.getSureDefaultItemsKey()).keyVersion).to.equal(oldVersion) + }) + }) +}) diff --git a/packages/snjs/mocha/utils.test.js b/packages/snjs/mocha/utils.test.js new file mode 100644 index 000000000..a525a8fe6 --- /dev/null +++ b/packages/snjs/mocha/utils.test.js @@ -0,0 +1,256 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable no-undef */ +chai.use(chaiAsPromised) +const expect = chai.expect + +describe('utils', () => { + it('findInArray', async () => { + expect(findInArray).to.be.ok + const array = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }] + expect(findInArray(array, 'id', 1)).to.be.ok + expect(findInArray(array, 'id', 'foo')).to.not.be.ok + }) + + it('isNullOrUndefined', () => { + expect(isNullOrUndefined(null)).to.equal(true) + expect(isNullOrUndefined(undefined)).to.equal(true) + expect(isNullOrUndefined(1)).to.equal(false) + expect(isNullOrUndefined('foo')).to.equal(false) + expect(isNullOrUndefined({})).to.equal(false) + expect(isNullOrUndefined([null])).to.equal(false) + }) + + it('isValidUrl', () => { + expect(isValidUrl('http://foo.com')).to.equal(true) + expect(isValidUrl('https://foo.com')).to.equal(true) + expect(isValidUrl('http://localhost:3000')).to.equal(true) + expect(isValidUrl('http://localhost:3000/foo/bar')).to.equal(true) + expect(isValidUrl('http://192.168.1:3000/foo/bar')).to.equal(true) + expect(isValidUrl('://foo.com')).to.equal(false) + expect(isValidUrl('{foo}/foo/com')).to.equal(false) + expect(isValidUrl('foo.com')).to.equal(false) + expect(isValidUrl('www.foo.com')).to.equal(false) + }) + + it('extendArray', () => { + const array = [1, 2, 3] + const original = array.slice() + const extended = [4, 5, 6] + extendArray(array, extended) + expect(array).to.eql(original.concat(extended)) + }) + + it('arraysEqual', () => { + expect(arraysEqual([1, 2, 3], [3, 2, 1])).to.equal(true) + expect(arraysEqual([2, 3], [3, 2, 1])).to.equal(false) + expect(arraysEqual([1, 2], [1, 2, 2])).to.equal(false) + expect(arraysEqual([1, 2, 3], [2, 3, 1])).to.equal(true) + expect(arraysEqual([1], [3])).to.equal(false) + }) + + it('top level compare', () => { + const left = { a: 1, b: 2 } + const right = { a: 1, b: 2 } + const middle = { a: 2, b: 1 } + expect(topLevelCompare(left, right)).to.equal(true) + expect(topLevelCompare(right, left)).to.equal(true) + expect(topLevelCompare(left, middle)).to.equal(false) + expect(topLevelCompare(middle, right)).to.equal(false) + }) + + it('jsonParseEmbeddedKeys', () => { + const object = { + a: { foo: 'bar' }, + b: JSON.stringify({ foo: 'bar' }), + } + const parsed = jsonParseEmbeddedKeys(object) + expect(typeof parsed.a).to.equal('object') + expect(typeof parsed.b).to.equal('object') + }) + + it('omitUndefined', () => { + const object = { + foo: '123', + bar: undefined, + } + const omitted = omitUndefinedCopy(object) + expect(Object.keys(omitted).includes('bar')).to.equal(false) + }) + + it('dateSorted', () => { + const objects = [{ date: new Date(10) }, { date: new Date(5) }, { date: new Date(7) }] + + /** ascending */ + const ascending = dateSorted(objects, 'date', true) + expect(ascending[0].date.getTime()).to.equal(5) + expect(ascending[1].date.getTime()).to.equal(7) + expect(ascending[2].date.getTime()).to.equal(10) + + /** descending */ + const descending = dateSorted(objects, 'date', false) + expect(descending[0].date.getTime()).to.equal(10) + expect(descending[1].date.getTime()).to.equal(7) + expect(descending[2].date.getTime()).to.equal(5) + }) + + describe('subtractFromArray', () => { + it('Removes all items appearing in the array', () => { + const array = [1, 2, 3, 4, 5] + subtractFromArray(array, [1, 3, 5]) + expect(array).to.eql([2, 4]) + }) + + it('Ignores items not appearing in the array', () => { + const array = [1, 2, 3, 4, 5] + subtractFromArray(array, [0, 1, 3, 5]) + expect(array).to.eql([2, 4]) + }) + }) + + describe('removeFromArray', () => { + it('Removes the first item appearing in the array', () => { + const array = [1, 1, 2, 3] + removeFromArray(array, 1) + expect(array).to.eql([1, 2, 3]) + removeFromArray(array, 2) + expect(array).to.eql([1, 3]) + }) + + it('Ignores items not appearing in the array', () => { + const array = [1, 2, 3] + removeFromArray(array, 0) + expect(array).to.eql([1, 2, 3]) + removeFromArray(array, {}) + }) + }) + + it('removeFromIndex', () => { + const array = [1, 2, 3] + removeFromIndex(array, 1) + expect(array).to.eql([1, 3]) + }) + + it('arrayByDifference', () => { + const array = [1, 2, 3, 4] + const array2 = [2, 3] + const result = arrayByDifference(array, array2) + expect(result).to.eql([1, 4]) + }) + + it('uniqCombineObjArrays', () => { + const arrayA = [{ a: 'a', b: 'a' }] + const arrayB = [ + { a: 'a', b: 'a' }, + { a: '2', b: '2' }, + ] + + const result = uniqCombineObjArrays(arrayA, arrayB, ['a', 'b']) + expect(result.length).to.equal(2) + }) + + it('uniqueArrayByKey', () => { + const arrayA = [{ uuid: 1 }, { uuid: 2 }] + const arrayB = [{ uuid: 1 }, { uuid: 2 }, { uuid: 1 }, { uuid: 2 }] + + const result = uniqueArrayByKey(arrayA.concat(arrayB), ['uuid']) + expect(result.length).to.equal(2) + }) + + it('filterFromArray function predicate', () => { + const array = [{ uuid: 1 }, { uuid: 2 }, { uuid: 3 }] + + filterFromArray(array, (o) => o.uuid === 1) + expect(array.length).to.equal(2) + }) + + it('lodash merge should behave as expected', () => { + const a = { + content: { + references: [{ a: 'a' }], + }, + } + const b = { + content: { + references: [], + }, + } + // merging a with b should replace total content + deepMerge(a, b) + expect(a.content.references).to.eql([]) + }) + + it('truncates hex string', () => { + const hex256 = 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b' + const desiredBits = 128 + const expectedLength = 32 + const result = truncateHexString(hex256, desiredBits) + expect(result.length).to.equal(expectedLength) + }) + + it('convertTimestampToMilliseconds', () => { + expect(convertTimestampToMilliseconds(1633636950)).to.equal(1633636950000) + expect(convertTimestampToMilliseconds(1633636950123)).to.equal(1633636950123) + expect(convertTimestampToMilliseconds(1633636950123456)).to.equal(1633636950123) + }) + + describe('isSameDay', () => { + it('returns true if two dates are on the same day', () => { + const dateA = new Date(2021, 1, 16, 16, 30, 0) + const dateB = new Date(2021, 1, 16, 17, 30, 0) + + const result = isSameDay(dateA, dateB) + expect(result).to.equal(true) + }) + + it('returns false if two dates are not on the same day', () => { + const dateA = new Date(2021, 1, 16, 16, 30, 0) + const dateB = new Date(2021, 1, 17, 17, 30, 0) + + const result = isSameDay(dateA, dateB) + expect(result).to.equal(false) + }) + }) + + describe('naturalSort', () => { + let items + beforeEach(() => { + items = [ + { + someProperty: 'a', + }, + { + someProperty: 'b', + }, + { + someProperty: '2', + }, + { + someProperty: 'A', + }, + { + someProperty: '1', + }, + ] + }) + + it('sorts elements in natural order in ascending direction by default', () => { + const result = naturalSort(items, 'someProperty') + expect(result).lengthOf(items.length) + expect(result[0]).to.equal(items[4]) + expect(result[1]).to.equal(items[2]) + expect(result[2]).to.equal(items[0]) + expect(result[3]).to.equal(items[3]) + expect(result[4]).to.equal(items[1]) + }) + + it('sorts elements in natural order in descending direction', () => { + const result = naturalSort(items, 'someProperty', 'desc') + expect(result).lengthOf(items.length) + expect(result[0]).to.equal(items[1]) + expect(result[1]).to.equal(items[3]) + expect(result[2]).to.equal(items[0]) + expect(result[3]).to.equal(items[2]) + expect(result[4]).to.equal(items[4]) + }) + }) +}) diff --git a/packages/snjs/mocha/vendor/chai-as-promised-built.js b/packages/snjs/mocha/vendor/chai-as-promised-built.js new file mode 100644 index 000000000..5f1d2f6e1 --- /dev/null +++ b/packages/snjs/mocha/vendor/chai-as-promised-built.js @@ -0,0 +1,539 @@ +(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.chaiAsPromised = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i { + const Assertion = chai.Assertion; + const assert = chai.assert; + const proxify = utils.proxify; + + // If we are using a version of Chai that has checkError on it, + // we want to use that version to be consistent. Otherwise, we use + // what was passed to the factory. + if (utils.checkError) { + checkError = utils.checkError; + } + + function isLegacyJQueryPromise(thenable) { + // jQuery promises are Promises/A+-compatible since 3.0.0. jQuery 3.0.0 is also the first version + // to define the catch method. + return typeof thenable.catch !== "function" && + typeof thenable.always === "function" && + typeof thenable.done === "function" && + typeof thenable.fail === "function" && + typeof thenable.pipe === "function" && + typeof thenable.progress === "function" && + typeof thenable.state === "function"; + } + + function assertIsAboutPromise(assertion) { + if (typeof assertion._obj.then !== "function") { + throw new TypeError(utils.inspect(assertion._obj) + " is not a thenable."); + } + if (isLegacyJQueryPromise(assertion._obj)) { + throw new TypeError("Chai as Promised is incompatible with thenables of jQuery<3.0.0, sorry! Please " + + "upgrade jQuery or use another Promises/A+ compatible library (see " + + "http://promisesaplus.com/)."); + } + } + + function proxifyIfSupported(assertion) { + return proxify === undefined ? assertion : proxify(assertion); + } + + function method(name, asserter) { + utils.addMethod(Assertion.prototype, name, function () { + assertIsAboutPromise(this); + return asserter.apply(this, arguments); + }); + } + + function property(name, asserter) { + utils.addProperty(Assertion.prototype, name, function () { + assertIsAboutPromise(this); + return proxifyIfSupported(asserter.apply(this, arguments)); + }); + } + + function doNotify(promise, done) { + promise.then(() => done(), done); + } + + // These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`. + function assertIfNegated(assertion, message, extra) { + assertion.assert(true, null, message, extra.expected, extra.actual); + } + + function assertIfNotNegated(assertion, message, extra) { + assertion.assert(false, message, null, extra.expected, extra.actual); + } + + function getBasePromise(assertion) { + // We need to chain subsequent asserters on top of ones in the chain already (consider + // `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass. + // So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e. + // previously derived promises, to chain off of. + return typeof assertion.then === "function" ? assertion : assertion._obj; + } + + function getReasonName(reason) { + return reason instanceof Error ? reason.toString() : checkError.getConstructorName(reason); + } + + // Grab these first, before we modify `Assertion.prototype`. + + const propertyNames = Object.getOwnPropertyNames(Assertion.prototype); + + const propertyDescs = {}; + for (const name of propertyNames) { + propertyDescs[name] = Object.getOwnPropertyDescriptor(Assertion.prototype, name); + } + + property("fulfilled", function () { + const derivedPromise = getBasePromise(this).then( + value => { + assertIfNegated(this, + "expected promise not to be fulfilled but it was fulfilled with #{act}", + { actual: value }); + return value; + }, + reason => { + assertIfNotNegated(this, + "expected promise to be fulfilled but it was rejected with #{act}", + { actual: getReasonName(reason) }); + return reason; + } + ); + + module.exports.transferPromiseness(this, derivedPromise); + return this; + }); + + property("rejected", function () { + const derivedPromise = getBasePromise(this).then( + value => { + assertIfNotNegated(this, + "expected promise to be rejected but it was fulfilled with #{act}", + { actual: value }); + return value; + }, + reason => { + assertIfNegated(this, + "expected promise not to be rejected but it was rejected with #{act}", + { actual: getReasonName(reason) }); + + // Return the reason, transforming this into a fulfillment, to allow further assertions, e.g. + // `promise.should.be.rejected.and.eventually.equal("reason")`. + return reason; + } + ); + + module.exports.transferPromiseness(this, derivedPromise); + return this; + }); + + method("rejectedWith", function (errorLike, errMsgMatcher, message) { + let errorLikeName = null; + const negate = utils.flag(this, "negate") || false; + + // rejectedWith with that is called without arguments is + // the same as a plain ".rejected" use. + if (errorLike === undefined && errMsgMatcher === undefined && + message === undefined) { + /* eslint-disable no-unused-expressions */ + return this.rejected; + /* eslint-enable no-unused-expressions */ + } + + if (message !== undefined) { + utils.flag(this, "message", message); + } + + if (errorLike instanceof RegExp || typeof errorLike === "string") { + errMsgMatcher = errorLike; + errorLike = null; + } else if (errorLike && errorLike instanceof Error) { + errorLikeName = errorLike.toString(); + } else if (typeof errorLike === "function") { + errorLikeName = checkError.getConstructorName(errorLike); + } else { + errorLike = null; + } + const everyArgIsDefined = Boolean(errorLike && errMsgMatcher); + + let matcherRelation = "including"; + if (errMsgMatcher instanceof RegExp) { + matcherRelation = "matching"; + } + + const derivedPromise = getBasePromise(this).then( + value => { + let assertionMessage = null; + let expected = null; + + if (errorLike) { + assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with #{act}"; + expected = errorLikeName; + } else if (errMsgMatcher) { + assertionMessage = `expected promise to be rejected with an error ${matcherRelation} #{exp} but ` + + `it was fulfilled with #{act}`; + expected = errMsgMatcher; + } + + assertIfNotNegated(this, assertionMessage, { expected, actual: value }); + return value; + }, + reason => { + const errorLikeCompatible = errorLike && (errorLike instanceof Error ? + checkError.compatibleInstance(reason, errorLike) : + checkError.compatibleConstructor(reason, errorLike)); + + const errMsgMatcherCompatible = errMsgMatcher && checkError.compatibleMessage(reason, errMsgMatcher); + + const reasonName = getReasonName(reason); + + if (negate && everyArgIsDefined) { + if (errorLikeCompatible && errMsgMatcherCompatible) { + this.assert(true, + null, + "expected promise not to be rejected with #{exp} but it was rejected " + + "with #{act}", + errorLikeName, + reasonName); + } + } else { + if (errorLike) { + this.assert(errorLikeCompatible, + "expected promise to be rejected with #{exp} but it was rejected with #{act}", + "expected promise not to be rejected with #{exp} but it was rejected " + + "with #{act}", + errorLikeName, + reasonName); + } + + if (errMsgMatcher) { + this.assert(errMsgMatcherCompatible, + `expected promise to be rejected with an error ${matcherRelation} #{exp} but got ` + + `#{act}`, + `expected promise not to be rejected with an error ${matcherRelation} #{exp}`, + errMsgMatcher, + checkError.getMessage(reason)); + } + } + + return reason; + } + ); + + module.exports.transferPromiseness(this, derivedPromise); + return this; + }); + + property("eventually", function () { + utils.flag(this, "eventually", true); + return this; + }); + + method("notify", function (done) { + doNotify(getBasePromise(this), done); + return this; + }); + + method("become", function (value, message) { + return this.eventually.deep.equal(value, message); + }); + + // ### `eventually` + + // We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage. + const methodNames = propertyNames.filter(name => { + return name !== "assert" && typeof propertyDescs[name].value === "function"; + }); + + methodNames.forEach(methodName => { + Assertion.overwriteMethod(methodName, originalMethod => function () { + return doAsserterAsyncAndAddThen(originalMethod, this, arguments); + }); + }); + + const getterNames = propertyNames.filter(name => { + return name !== "_obj" && typeof propertyDescs[name].get === "function"; + }); + + getterNames.forEach(getterName => { + // Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as + // `should.be.an("object")`. We need to handle those specially. + const isChainableMethod = Assertion.prototype.__methods.hasOwnProperty(getterName); + + if (isChainableMethod) { + Assertion.overwriteChainableMethod( + getterName, + originalMethod => function () { + return doAsserterAsyncAndAddThen(originalMethod, this, arguments); + }, + originalGetter => function () { + return doAsserterAsyncAndAddThen(originalGetter, this); + } + ); + } else { + Assertion.overwriteProperty(getterName, originalGetter => function () { + return proxifyIfSupported(doAsserterAsyncAndAddThen(originalGetter, this)); + }); + } + }); + + function doAsserterAsyncAndAddThen(asserter, assertion, args) { + // Since we're intercepting all methods/properties, we need to just pass through if they don't want + // `eventually`, or if we've already fulfilled the promise (see below). + if (!utils.flag(assertion, "eventually")) { + asserter.apply(assertion, args); + return assertion; + } + + const derivedPromise = getBasePromise(assertion).then(value => { + // Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and + // now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code, + // just the base Chai code that we get to via the short-circuit above. + assertion._obj = value; + utils.flag(assertion, "eventually", false); + + return args ? module.exports.transformAsserterArgs(args) : args; + }).then(newArgs => { + asserter.apply(assertion, newArgs); + + // Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object" + // flag), we need to communicate this value change to subsequent chained asserters. Since we build a + // promise chain paralleling the asserter chain, we can use it to communicate such changes. + return assertion._obj; + }); + + module.exports.transferPromiseness(assertion, derivedPromise); + return assertion; + } + + // ### Now use the `Assertion` framework to build an `assert` interface. + const originalAssertMethods = Object.getOwnPropertyNames(assert).filter(propName => { + return typeof assert[propName] === "function"; + }); + + assert.isFulfilled = (promise, message) => (new Assertion(promise, message)).to.be.fulfilled; + + assert.isRejected = (promise, errorLike, errMsgMatcher, message) => { + const assertion = new Assertion(promise, message); + return assertion.to.be.rejectedWith(errorLike, errMsgMatcher, message); + }; + + assert.becomes = (promise, value, message) => assert.eventually.deepEqual(promise, value, message); + + assert.doesNotBecome = (promise, value, message) => assert.eventually.notDeepEqual(promise, value, message); + + assert.eventually = {}; + originalAssertMethods.forEach(assertMethodName => { + assert.eventually[assertMethodName] = function (promise) { + const otherArgs = Array.prototype.slice.call(arguments, 1); + + let customRejectionHandler; + const message = arguments[assert[assertMethodName].length - 1]; + if (typeof message === "string") { + customRejectionHandler = reason => { + throw new chai.AssertionError(`${message}\n\nOriginal reason: ${utils.inspect(reason)}`); + }; + } + + const returnedPromise = promise.then( + fulfillmentValue => assert[assertMethodName].apply(assert, [fulfillmentValue].concat(otherArgs)), + customRejectionHandler + ); + + returnedPromise.notify = done => { + doNotify(returnedPromise, done); + }; + + return returnedPromise; + }; + }); +}; + +module.exports.transferPromiseness = (assertion, promise) => { + assertion.then = promise.then.bind(promise); +}; + +module.exports.transformAsserterArgs = values => values; + +},{"check-error":2}],2:[function(require,module,exports){ +'use strict'; + +/* ! + * Chai - checkError utility + * Copyright(c) 2012-2016 Jake Luer + * MIT Licensed + */ + +/** + * ### .checkError + * + * Checks that an error conforms to a given set of criteria and/or retrieves information about it. + * + * @api public + */ + +/** + * ### .compatibleInstance(thrown, errorLike) + * + * Checks if two instances are compatible (strict equal). + * Returns false if errorLike is not an instance of Error, because instances + * can only be compatible if they're both error instances. + * + * @name compatibleInstance + * @param {Error} thrown error + * @param {Error|ErrorConstructor} errorLike object to compare against + * @namespace Utils + * @api public + */ + +function compatibleInstance(thrown, errorLike) { + return errorLike instanceof Error && thrown === errorLike; +} + +/** + * ### .compatibleConstructor(thrown, errorLike) + * + * Checks if two constructors are compatible. + * This function can receive either an error constructor or + * an error instance as the `errorLike` argument. + * Constructors are compatible if they're the same or if one is + * an instance of another. + * + * @name compatibleConstructor + * @param {Error} thrown error + * @param {Error|ErrorConstructor} errorLike object to compare against + * @namespace Utils + * @api public + */ + +function compatibleConstructor(thrown, errorLike) { + if (errorLike instanceof Error) { + // If `errorLike` is an instance of any error we compare their constructors + return thrown.constructor === errorLike.constructor || thrown instanceof errorLike.constructor; + } else if (errorLike.prototype instanceof Error || errorLike === Error) { + // If `errorLike` is a constructor that inherits from Error, we compare `thrown` to `errorLike` directly + return thrown.constructor === errorLike || thrown instanceof errorLike; + } + + return false; +} + +/** + * ### .compatibleMessage(thrown, errMatcher) + * + * Checks if an error's message is compatible with a matcher (String or RegExp). + * If the message contains the String or passes the RegExp test, + * it is considered compatible. + * + * @name compatibleMessage + * @param {Error} thrown error + * @param {String|RegExp} errMatcher to look for into the message + * @namespace Utils + * @api public + */ + +function compatibleMessage(thrown, errMatcher) { + var comparisonString = typeof thrown === 'string' ? thrown : thrown.message; + if (errMatcher instanceof RegExp) { + return errMatcher.test(comparisonString); + } else if (typeof errMatcher === 'string') { + return comparisonString.indexOf(errMatcher) !== -1; // eslint-disable-line no-magic-numbers + } + + return false; +} + +/** + * ### .getFunctionName(constructorFn) + * + * Returns the name of a function. + * This also includes a polyfill function if `constructorFn.name` is not defined. + * + * @name getFunctionName + * @param {Function} constructorFn + * @namespace Utils + * @api private + */ + +var functionNameMatch = /\s*function(?:\s|\s*\/\*[^(?:*\/)]+\*\/\s*)*([^\(\/]+)/; +function getFunctionName(constructorFn) { + var name = ''; + if (typeof constructorFn.name === 'undefined') { + // Here we run a polyfill if constructorFn.name is not defined + var match = String(constructorFn).match(functionNameMatch); + if (match) { + name = match[1]; + } + } else { + name = constructorFn.name; + } + + return name; +} + +/** + * ### .getConstructorName(errorLike) + * + * Gets the constructor name for an Error instance or constructor itself. + * + * @name getConstructorName + * @param {Error|ErrorConstructor} errorLike + * @namespace Utils + * @api public + */ + +function getConstructorName(errorLike) { + var constructorName = errorLike; + if (errorLike instanceof Error) { + constructorName = getFunctionName(errorLike.constructor); + } else if (typeof errorLike === 'function') { + // If `err` is not an instance of Error it is an error constructor itself or another function. + // If we've got a common function we get its name, otherwise we may need to create a new instance + // of the error just in case it's a poorly-constructed error. Please see chaijs/chai/issues/45 to know more. + constructorName = getFunctionName(errorLike).trim() || + getFunctionName(new errorLike()); // eslint-disable-line new-cap + } + + return constructorName; +} + +/** + * ### .getMessage(errorLike) + * + * Gets the error message from an error. + * If `err` is a String itself, we return it. + * If the error has no message, we return an empty string. + * + * @name getMessage + * @param {Error|String} errorLike + * @namespace Utils + * @api public + */ + +function getMessage(errorLike) { + var msg = ''; + if (errorLike && errorLike.message) { + msg = errorLike.message; + } else if (typeof errorLike === 'string') { + msg = errorLike; + } + + return msg; +} + +module.exports = { + compatibleInstance: compatibleInstance, + compatibleConstructor: compatibleConstructor, + compatibleMessage: compatibleMessage, + getMessage: getMessage, + getConstructorName: getConstructorName, +}; + +},{}]},{},[1])(1) +}); diff --git a/packages/snjs/mocha/workspaces.test.js b/packages/snjs/mocha/workspaces.test.js new file mode 100644 index 000000000..cdb96c4cd --- /dev/null +++ b/packages/snjs/mocha/workspaces.test.js @@ -0,0 +1,25 @@ +chai.use(chaiAsPromised) +const expect = chai.expect +import * as Factory from './lib/factory.js' + +describe('private workspaces', () => { + it('generates identifier', async () => { + const userphrase = 'myworkspaceuserphrase' + const name = 'myworkspacename' + + const result = await ComputePrivateWorkspaceIdentifier(new SNWebCrypto(), userphrase, name) + + expect(result).to.equal('5155c13a44f333790f6564fbcee0c35a16d26a8359dd77d67d8ecc6ad5d399bb') + }) + + it('application result matches direct function call', async () => { + const userphrase = 'myworkspaceuserphrase' + const name = 'myworkspacename' + + const application = (await Factory.createAppContextWithRealCrypto()).application + const appResult = await application.computePrivateWorkspaceIdentifier(userphrase, name) + const directResult = await ComputePrivateWorkspaceIdentifier(new SNWebCrypto(), userphrase, name) + + expect(appResult).to.equal(directResult) + }) +}) diff --git a/packages/snjs/package.json b/packages/snjs/package.json new file mode 100644 index 000000000..e13bf1f47 --- /dev/null +++ b/packages/snjs/package.json @@ -0,0 +1,90 @@ +{ + "name": "@standardnotes/snjs", + "version": "2.122.0", + "engines": { + "node": ">=16.0.0 <17.0.0" + }, + "main": "dist/snjs.js", + "author": "Standard Notes", + "types": "dist/@types", + "files": [ + "dist" + ], + "license": "AGPL-3.0-or-later", + "publishConfig": { + "access": "public" + }, + "scripts": { + "start": "webpack -w --config webpack.dev.js", + "clean": "rm -fr dist", + "prebuild": "yarn clean", + "build": "yarn tsc && webpack --config webpack.prod.js", + "docs": "jsdoc -c jsdoc.json", + "tsc": "tsc --project lib/tsconfig.json && tscpaths -p lib/tsconfig.json -s lib -o dist/@types", + "lint": "yarn lint:tsc && yarn lint:eslint", + "lint:eslint": "eslint --ext .ts lib/", + "lint:tsc": "tsc --noEmit --emitDeclarationOnly false --project lib/tsconfig.json", + "test:unit": "jest spec --coverage", + "test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@babel/preset-env": "^7.16.11", + "@types/crypto-js": "^4.1.1", + "@types/jest": "^28.1.4", + "@types/jsdom": "^16.2.14", + "@types/libsodium-wrappers": "^0.7.9", + "@types/lodash": "^4.14.179", + "@types/semver": "^7.3.10", + "@typescript-eslint/eslint-plugin": "^5.30.0", + "babel-jest": "^28.1.2", + "babel-loader": "^8.2.3", + "chai": "^4.3.6", + "chai-as-promised": "^7.1.1", + "chai-subset": "^1.6.0", + "circular-dependency-plugin": "^5.2.2", + "crypto-js": "^4.1.1", + "docdash": "^1.2.0", + "dom-storage": "^2.1.0", + "eslint-plugin-prettier": "^4.2.1", + "exports-loader": "^3.1.0", + "jest": "^28.1.2", + "jest-environment-jsdom": "^28.1.2", + "jsdom": "^19.0.0", + "libsodium-wrappers": "^0.7.9", + "lodash": "^4.17.21", + "mocha": "^9.2.1", + "mocha-headless-chrome": "^4.0.0", + "nock": "^13.2.4", + "otplib": "^12.0.1", + "regenerator-runtime": "^0.13.9", + "script-loader": "^0.7.2", + "sinon": "^13.0.1", + "ts-jest": "^28.0.5", + "ts-loader": "^9.2.6", + "ts-node": "^10.8.1", + "tscpaths": "0.0.9", + "uuid": "^8.3.2", + "webpack": "^5.69.1", + "webpack-cli": "^4.9.2", + "webpack-merge": "^5.8.0" + }, + "dependencies": { + "@standardnotes/api": "workspace:*", + "@standardnotes/common": "^1.25.0", + "@standardnotes/domain-events": "^2.39.0", + "@standardnotes/encryption": "workspace:*", + "@standardnotes/features": "workspace:*", + "@standardnotes/files": "workspace:*", + "@standardnotes/models": "workspace:*", + "@standardnotes/responses": "workspace:*", + "@standardnotes/security": "^1.2.0", + "@standardnotes/services": "workspace:*", + "@standardnotes/settings": "^1.17.0", + "@standardnotes/sncrypto-common": "workspace:*", + "@standardnotes/sncrypto-web": "workspace:*", + "@standardnotes/utils": "workspace:*", + "reflect-metadata": "^0.1.13", + "semver": "^7.3.7" + } +} diff --git a/packages/snjs/specification.md b/packages/snjs/specification.md new file mode 100644 index 000000000..9db2c8978 --- /dev/null +++ b/packages/snjs/specification.md @@ -0,0 +1,304 @@ +# Protocol Specification | v004 + +The 004 protocol upgrade centers around a system that makes it easy and painless to upgrade to a future protocol version, as well as more modern cryptographic primitives. + +## Introduction + +The Standard Notes Protocol describes a set of procedures that ensure client-side encryption of data in such a way that makes it impossible for the server, which houses the data, to read or decrypt the data. It treats the server as a dumb data-store that simply saves and returns values on demand. + +Even in scenarios when the server is under active attack, clients should be fully protected, and cannot be tricked into revealing any sensitive information. + +The client and server communicate under two common procedures: authentication, and syncing. + +Authentication is a one-time transfer of information between client and server. In short, clients generate a long secret key by stretching a user-inputted password using a KDF. The first half of that key is kept locally as the "master key" and is never revealed to the server. The second half of that key is sent to the server as the "account server password". + +The master key is then used to encrypt an arbitrary number of items keys. Items keys are generated randomly and not based on the account password. Items keys are used to encrypt syncable data, like notes, tags, and user preferences. Items keys themselves are also synced to user accounts, and are encrypted directly with the master key. + +When a user's master key changes, all items keys must be re-encrypted with the new master key. Accounts should generally have one items key per protocol version, so even in the event where many protocol upgrades are created, only a few KB of data must be re-encrypted when a user's credentials change (as opposed to completely re-encrypting many megabytes or gigabytes of data). + +Data is also encrypted client-side for on-device storage. When an account is present, all local data is encrypted by default, including simple key-value storage (similar to a localStorage-like store). Persistence stores are always encrypted with the account master key, and the master key is stored in the device's secure keychain (when available). + +Clients also have the option of configuring an application passcode, which wraps the account master key with an additional layer of encryption. Having a passcode enabled is referred to as having a "root key wrapper" enabled. When a root key is wrapped, it is stored in local storage as an encrypted payload, and the keychain is bypassed. This allows for secure key storage even in environments that don't expose a keychain, such as web browsers. + +This document delineates client-side procedures for key management and generation, data encryption, and storage encryption. Concepts related to server syncing and server session management are outside the scope of this document. This document however wholly covers any values that a server would receive, so even though syncing and server session management is out of scope, the procedures outlined in this document should guarantee that no secret value is ever revealed to the server. + +## Key Management + +**There are three main concepts as related to keys:** + +1. **A root key**—based on an account's user-inputted password. There exists only one root key per account. +2. **A root key wrapper**—_wraps_ a root key (encrypts it) with an additional layer. This is a local-only construct, and translates directly as an "application passcode" feature. +3. **Items keys**—used to encrypt items. There can exist many items keys, and one items key can encrypt many items. Each items key is encrypted with the root key. When the root key changes, all items keys must be re-encrypted using the new root key. + +### Key Generation Flow + +1. User registers with an email (`identifier`) and a `password`. +2. `password` is run through a KDF to generate a key, which is then split in two, as part of a single `rootKey`. + 1. The first half is the `masterKey`. + 2. The second half is the `serverPassword`. +3. Client registers user account with server using `email` and `rootKey.serverPassword`. +4. Client creates new random key `itemsKey`. This key is encrypted directly with `rootKey.masterKey`, and the encrypted `itemsKey` is assigned a UUID and uploaded to the user's account. (Each `itemsKey` is a traditional item, just like a note or tag.) + +### Password change or protocol upgrade flow + +**When a user changes their password, or when a new protocol version is available:** + +1. Client generates new `rootKey` using account identifier and password, and thus generates new `rootKey.masterKey`, `rootKey.serverPassword`, and `keyParams`, which include the protocol version and other public information used to guide clients on generating the `rootKey` given a user password. +2. Client submits new `rootKey.serverPassword` and `keyParams` to server. Note that the changing the `serverPassword` does not necessarily invalidate a user's session. Sessions management is outside of the scope of this document. +3. Client loops through all `itemsKeys` and re-encrypts them with new `rootKey.masterKey`. All `itemsKeys` are then re-uploaded to server. Note that `itemsKeys` are immutable and their inner key never changes. The key is only re-encrypted using the new `masterKey`. + +This flow means that when a new protocol version is available or when a user changes their password, we do not need to re-encrypt all their data, but instead only a handful of keys. + +### Key Rotation + +By default, upgrading an account's protocol version will create a new `itemsKey` for that version, and that key will be used to encrypt all data going forward. To prevent large-scale data modification that may take hours to complete, any data encrypted with a previous `itemsKey` will be re-encrypted with the new `itemsKey` progressively, and not all at once. This progressive re-encryption occurs when an item is explicitly modified by the user. Applications can also be designed to bulk-modify items during idle-capacity, without user interaction. + +**When changing the account password:** + +- If a new protocol version is available, changing the account password will also upgrade to the latest protocol version and thus generates a new default `itemsKey`. +- If no new protocol version is available, or if the user is already using the latest version, changing the account password generates a new `rootKey`, as well as generates a new `itemsKey`. The new `itemsKey` will be used as the default items encryption key, and will also be used to progressively re-encrypt previous data. Generating a new `itemsKey` on password change ensures backward secrecy in the case the previous account password is compromised. + +## Encryption Flow + +_For each_ item (such as a note) the client wants to encrypt: +1. Client generates random `item_key` (note: singular. Not related to `itemsKey`). +2. Client encrypts note content with `item_key` to form `content`. +3. Client encrypts `item_key` with default `itemsKey` as `enc_item_key`. +4. Client notes `itemsKey` UUID and associates it with encrypted item payload as `items_key_id`, and uploads payload to server. + +To decrypt an item payload: +1. Client retrieves `itemsKey` matching `items_key_id` of payload. +2. Client decrypts item's `enc_item_key` with `itemsKey` to form `item_key`. +3. Client decrypts item's `content` using `item_key`. + +## Authentication + +Registering for an account involves generating a `rootKey` and respective `keyParams`, according to the key generation flow above. The key parameters are uploaded to the server, and include: + +- unique identifier (email) +- salt seed +- protocol version + +To sign into an account, clients first make a request to the server to retrieve the key params for a given email. This endpoint is public and non-authenticated (unless the account has two-factor authentication enabled). The client then uses the retrieved key params to generate a `rootKey`, and uses the `rootKey.serverPassword` to authenticate the account. + +Note that by default, the client trusts the protocol version the server reports. The client uses this protocol version to determine which cryptographic primitives (and their parameters) to use for key generation. This raises the question of, what happens if a malicious server underreports an account's version in order to weaken key generation parameters? For example, if a user's account is 004, but the server reports 002, the client will proceed to generate a `serverPassword` using outdated primitives. + +There are two safeguards against this scenario: + +1. Older protocol versions are expired and become no longer supported after a certain period. +2. Clients may sign in with a flag known as "strict sign in" (SSI). SSI ensures that the client _always_ signs in with the client-side _hardcoded latest version_ of the protocol. For example, if a client with SNJS 004 support attempts to sign in with SSI enabled, and the server reports a protocol version of 002 for a given account, the client will refuse this sign-in, and will not proceed with key generation. SSI is a user-controlled option. Clients cannot be programmed to default to SSI, as otherwise, users would be unable to sign in to their account whenever a new protocol version is available. + +## Root Key Wrapping + +Root key wrapping is a local-only construct that pertains to how the root key is stored locally. By default, and with no root key wrapping, the `rootKey` is stored in the secure device keychain. Only the `rootKey.masterKey` is stored locally; the `rootKey.serverPassword` is never stored locally, and is only used for initial account registration. If no keychain is available (web browsers), the `rootKey` is stored in storage in necessarily plain format. + +Root key wrapping allows the client to encrypt the `rootKey` before storing it to disk. Wrapping a root key consists of: + +1. Client asks user to choose a "local passcode". +2. The local passcode is run through the same key generation flow as account registration (using a random UUID as the account identifier, in place of an email) to generate a separate new root key known as the `rootKeyWrappingKey` (which likewise consists of a `masterKey` and an unused `serverPassword`). +3. The `rootKeyWrappingKey` is used to encrypt the `rootKey` as `wrappedRootKey`. The `wrappedRootKey` (along with `wrappingKeyKeyParams`) is stored directly in storage, and the keychain is cleared of previous unwrapped `rootKey`. (Some keychains have fixed payload size limit, so an encrypted payload may not always fit. For this reason `wrappedRootKey` is always stored directly in storage.) + +**To unwrap a root key:** + +1. Client displays an "Enter your local passcode" prompt to user. +2. Client runs user-inputted password through key generation scheme (using stored `wrappingKeyKeyParams`) to generate a temporary `rootKeyWrappingKey`. +3. Client attempts to decrypt `wrappedRootKey` using `rootKeyWrappingKey`. If the decryption process succeeds (no errors are thrown), the client successfully unlocks application, and keeps the unwrapped `rootKey` in application memory to aid in encryption and decryption of items (or rather `itemsKeys`, to be exact). + +**The purpose of root key wrapping is many-fold:** + +1. To allow for secure storage of root key when no secure keychain is available (i.e web browsers). +2. Even in cases when a keychain is available, root key wrapping allows users to choose an arbitrary password to protect their storage with. +3. To allow for encryption of local storage. +4. To allow applications to introduce cryptographically-backed UI-level app locking. + +When a root key is wrapped, no information about the wrapper is persisted locally or in memory beyond the `keyParams` for the wrapper. This includes any sort of hash for verification of the correctness of the entered local passcode. That is, when a user enters a local passcode, we know it is correct not because we compare one hash to another, but by whether it succeeds in decrypting some encrypted payload. + +## Multi-Client Root Key Changes + +Because account password changes (or, in general, root key changes) require all existing items keys to be re-encrypted with the new root key, it is possible that items keys eventually fall into an inconsistent state, such that some are encrypted with a newer root key, while others are encrypted with the new root key. Clients encountering an items key they cannot encrypt with the current account root key parameters would then reach a dead end, and users would see undecryptable data. + +To recover the ability to decrypt an items key, clients can use the `kp` (key params) included the items key's authenticated_data payload. These parameters represent the the key params of the root key used to encrypt this items key. + +For example, when the account password changes, and thus the root key changes, all items keys are re-encrypted with the new root key on client A. Another client (client B) who may have a valid API session, but an outdated root key, will be able to download these new items keys. However, when client B attempts to decrypt these keys using its root key, the decryption will fail. Client B enters a state where it can save items to the server (wherein those items are encrypted using its existing default readable items key), but cannot read new data encrypted with items keys encrypted with client A's root key. + +When client B connects to the API with a valid session token, but an outdated root key, it will be able to download new items keys, but not yet decrypt them. However, since the key parameters for the root key underlying the items key is included in the encrypted payload, the client will be able to prompt the user for their new password. + +**In general,** + +A. When a client encounters an items key it cannot decrypt, whose created date is greater than any existing items key it has, it will: + +1. Make an authenticated request to the server to retrieve the account's current key parameters (because we suspect that they may have changed, due to the above fact). Authenticated requests to the GET key_params endpoint bypasses the MFA requirement. +2. Verify that the incoming key params version is greater than or equal to the client's current key params version. For example, if the client's key params version is 004, but the incoming key params version is 003, the client will reject these parameters as insecure and abort this process. +3. Prompt the user for their account password, including in the prompt its reason. i.e _"Your account password was changed 3 days ago. Enter your new account password."_ +4. Validate the account password based on its root key's ability to decrypt the aforementioned items key. If it succeeds, replace the client's current root key with this new root key. + +At this point, this client is now in sync. It does not need to communicate with the server to handle updating its state after a password change. + +If the aforementioned items key's key params are not exactly equal to the server's key params (not a logical outcome, but assuming arbitrary desync), and no items keys exists with the same key params as the server key params, it must fallback to performing the regular sign in flow to authenticate its root key (based on its `serverPassword` field). + +B. When a client encounters an items key it cannot decrypt, regardless of its created date, and the server key parameters are equal to the ones the client has on hand, this indicates that the items key may be encrypted with an older root key (for whatever reason). + +In such cases, the client will present a "key recovery wizard", which all attempt to decrypt the stale items key: + +1. Retrieve the key parameters associated with the authenticated_data of the items key's payload. +2. Prompt the user for their account password as it was on the date the key parameters were created. For example, _"Enter your account password as it was on Oct 20, 2019, 6:15AM."_ +3. Generate a root key from the account password using the relevant key params, and use that root key to decrypt the stale items key. If the decryption is successful, the client will then decrypt any items associated with that items key. It will then mark the key as needing sync. +4. When the key subsequently runs through normal syncing logic, it will then proceed to be encrypted by the account's current root key, and synced to the account. + +The above procedure represents a "corrective" course of action in the case that the sync following a root key change, where all items keys must be re-encrypted with the new root key, fails silently and results in inconsistent data. + +Note that the difference between case A and case B is that in case A, we prompt the user for their account password and **update our client's root key** with the generated root key, if it is valid. In case B, we generate a temporary root key for decryption purposes only, but discard of the root key after our decryption. This distinction is important because in case A, the server will be required to return key parameters with version greater than or equal to the user's current version, but in case B, key parameters can be arbitrarily old. However, because in this case the root key is not used for anything other than transient read operations, we can accept protocol versions no matter how outdated they are. + +### Expired Sessions + +When a client encounters an invalid session network response (typically status code 498), it will: + +1. Retrieve the latest key parameters from the server. (Note that because GETting key parameters may require MFA authentication, clients must be prepared to handle an "mfa-required" error response.) +2. Ensure the key parameter version is greater than or equal to the version the client currently has on hand. +3. Prompt the user for their account password, indicating the reason. i.e _"Your session has expired. Please re-enter your account password to restore access to your account."_ +4. Proceed with normal sign in flow. + +## Storage + +**There exists three types of storage:** + +1. **Value storage**—values such as user preferences, session token, and other app-specific values. +2. **Payload storage**—encrypted item payloads (such as notes and tags). +3. **Root key storage**—the primary root key. + +How data is stored depends on different key scenarios. + +### Scenario A +_No root key and no root key wrapper (no account and no passcode)_ +- **Value storage**: Plain, unencrypted +- **Payload storage**: Plain, unencrypted +- **Root key storage**: Not applicable + +### Scenario B +_Root key but no root key wrapper (account but no passcode):_ +- **Value storage**: Encrypted with root key +- **Payload storage:** Encrypted with root key +- **Root key storage**: + - With device keychain: Plainly in secure keychain + - With no device keychain: Plainly in device storage + +### Scenario C +_Root key and root key wrapper (account and passcode):_ +- **Value storage**: Encrypted with root key +- **Payload storage**: Encrypted with root key +- **Root key storage**: Encrypted in device storage + +### Scenario D +_No root key but root key wrapper (no account but passcode):_ +- **Value storage**: Encrypted with root key wrapper +- **Payload storage**: Encrypted with root key wrapper +- **Root key storage**: Not applicable + +## 003 Migration + +For the most part, SNJS does not branch off into different modes of behavior for different protocol versions (apart from the version specific operators). This means that new constructs in 004, like items keys, are also used in 003. This is accomplished via migrations that are performed when the application detects older data state. + +In particular, when SNJS detects a pre-existing 003 account (before the user even has the chance to perform the protocol upgrade), a migration will be triggered that creates a default `itemsKey` using the account's current `rootKey.masterKey`: + +``` +itemsKey = { itemsKey: rootKey.masterKey, version: '003' } +``` + +This `itemsKey` is encrypted as usual using `rootKey.masterKey`, and synced to the user's account. When the user eventually performs the 004 upgrade (by entering their account password when prompted), a new `itemsKey` will be created as a default for 004. However, their previously created 003 `itemsKey` will continue to exist, so that data previously encrypted with 003 will still be decryptable. + +## Cryptography Specifics + +**Key Derivation:** + +| Name | Value | +|--------------------|----------| +| Algorithm | Argon2id | +| Memory (Bytes) | 67108864 | +| Iterations | 5 | +| Parallelism | 1 | +| Salt Length (Bits) | 128 | +| Output Key (Bits) | 512 | + +**Encryption:** + +| Name | Value | +|--------------------|--------------------| +| Algorithm | XChaCha20+Poly1305 | +| Key Length (Bits) | 256 | +| Nonce Length (Bits)| 192 | + +### Root Key Derivation Flow - Specifics + +Given a user `identifier` (email) and `password` (user password): +1. Generate a random salt `seed`, 256 bits (`hex`). +2. Generate `salt`: + 1. `hash = SHA256Hex('identifier:seed')` + 2. `salt = hash.substring(0, 32)` +3. Generate `derivedKey = argon2(password, salt, ITERATIONS, MEMORY, OUTPUT_LENGTH) ` +4. Generate `rootKey` as: + ``` + { + masterKey: derivedKey.firstHalf, + serverPassword: derivedKey.secondHalf, + version: '004' + } + ``` +5. For account registration, `identifier`, `seed`, `serverPassword`, and `version` must be uploaded to the server. + +**Understanding the salt `seed`:** + +Our threat model is intended to distrust the server as much as possible. For this reason, we do not want to blindly trust whatever salt value a server returns to us. For example, a malicious server may attempt to mass-weaken user security by sending the same salt for every user account, and observe what interesting results the clients send back. Instead, clients play a more significant role in salt generation, and use the value the user inputs into the email field for salt generation. + +At this point we have `salt = generateSalt(email)`. However, we'd ideally like to make this value more unique. Emails are globally unique, but well-known in advance. We could introduce more variability by also including the protocol version in salt computation, such as `salt = generateSalt(email, version)`, but this could also be well-accounted for in advance. + +The salt `seed` serves as a way to make it truly impossible to know a salt for an account ahead of time, without first interacting with the server the account is hosted on. While retrieving a `seed` for a given account is a public, non-authorized operation, users who configure two-factor authentication can proceed to lock this operation so that a proper 2FA code is required to retrieve the salt `seed`. Salts are thus computed via `salt = generateSalt(email, seed)`. + +### Items Key Generation Flow +1. Generate random `hex` string `key`, 256 bits. +2. Create `itemsKey = {itemsKey: key, version: '004'}` + +### Encryption - Specifics + +An encrypted payload consists of: +- `items_key_id`: The UUID of the `itemsKey` used to encrypt `enc_item_key`. +- `enc_item_key`: An encrypted protocol string joined by colons `:` of the following components: + - protocol version + - encryption nonce + - ciphertext + - authenticated_data +- `content`: An encrypted protocol string joined by colons `:` of the following components: + - protocol version + - encryption nonce + - ciphertext + - authenticated_data + +**Procedure to encrypt an item (such as a note):** + +1. Generate a random 256-bit key `item_key` (in `hex` format). +2. Encrypt `item.content` using `item_key` to form `content`, and `{ u: item.uuid, v: '004', kp: rootKey.key_params IF item.type == ItemsKey }` as `authenticated_data`, following the instructions _"Encrypting a string using the 004 scheme"_ below. +3. Encrypt `item_key` using the the default `itemsKey.itemsKey` to form `enc_item_key`, and `{ u: item.uuid, v: '004', kp: rootKey.key_params IF item.type == ItemsKey }` as `authenticated_data`, following the instructions _"Encrypting a string using the 004 scheme"_ below. +5. Generate an encrypted payload as: + ``` + { + items_key_id: itemsKey.uuid, + enc_item_key: enc_item_key, + content: content, + } + ``` + +### Encrypting a string using the 004 scheme: + +Given a `string_to_encrypt`, an `encryption_key`, `authenticated_data`, and an item's `uuid`: + +1. Generate a random 192-bit string called `nonce`. + +2. Encode `authenticated_data` as a base64 encoded json string (`base64(json(authenticated_data))`) where the embedded data is recursively sorted by key for stringification (i.e `{v: '2', 'u': '1'}` should be stringified as `{u: '1', 'v': '2'}`), to get `encoded_authenticated_data`. + +3. Encrypt `string_to_encrypt` using `XChaCha20+Poly1305:Base64`, `encryption_key`, `nonce`, and `encoded_authenticated_data`: + ``` + ciphertext = XChaCha20Poly1305(string_to_encrypt, encryption_key, nonce, encoded_authenticated_data) + ``` +4. Generate the final result by combining components into a `:` separated string: + ``` + result = ['004', nonce, ciphertext, encoded_authenticated_data].join(':') + ``` diff --git a/packages/snjs/webpack.config.js b/packages/snjs/webpack.config.js new file mode 100644 index 000000000..67b581149 --- /dev/null +++ b/packages/snjs/webpack.config.js @@ -0,0 +1,60 @@ +const path = require('path'); +const webpack = require('webpack'); +const CircularDependencyPlugin = require('circular-dependency-plugin'); + +module.exports = { + entry: { + 'snjs.js': './lib/index.ts', + }, + resolve: { + extensions: ['.ts', '.js'], + alias: { + '@Lib': path.resolve(__dirname, 'lib'), + '@Services': path.resolve(__dirname, 'lib/services'), + '@Payloads': path.resolve(__dirname, 'lib/protocol/payloads'), + }, + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: './[name]', + library: 'SNLibrary', + libraryTarget: 'umd', + umdNamedDefine: true, + publicPath: '/dist/', + }, + optimization: { + minimize: false, + }, + module: { + rules: [ + { + test: /\.(js|ts)$/, + use: [ + 'babel-loader', + { + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + }, + ], + }, + ], + }, + plugins: [ + new webpack.DefinePlugin({ + __VERSION__: JSON.stringify(require('./package.json').version), + }), + new CircularDependencyPlugin({ + // exclude detection of files based on a RegExp + exclude: /a\.js|node_modules/, + // add errors to webpack instead of warnings + failOnError: false, + // allow import cycles that include an asyncronous import, + // e.g. via import(/* webpackMode: "weak" */ './file.js') + allowAsyncCycles: false, + // set the current working directory for displaying module paths + cwd: process.cwd(), + }), + ], +}; diff --git a/packages/snjs/webpack.dev.js b/packages/snjs/webpack.dev.js new file mode 100644 index 000000000..94c481407 --- /dev/null +++ b/packages/snjs/webpack.dev.js @@ -0,0 +1,10 @@ +const { merge } = require('webpack-merge'); +const config = require('./webpack.config.js'); + +module.exports = merge(config, { + mode: 'development', + devtool: 'eval-cheap-module-source-map', + stats: { + colors: true, + }, +}); diff --git a/packages/snjs/webpack.prod.js b/packages/snjs/webpack.prod.js new file mode 100644 index 000000000..5d991fbaa --- /dev/null +++ b/packages/snjs/webpack.prod.js @@ -0,0 +1,6 @@ +const { merge } = require('webpack-merge'); +const config = require('./webpack.config.js'); + +module.exports = merge(config, { + mode: 'production', +}); diff --git a/packages/web/package.json b/packages/web/package.json index 914ba5472..d321acf5d 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -74,7 +74,7 @@ "@standardnotes/icons": "workspace:*", "@standardnotes/services": "workspace:*", "@standardnotes/sncrypto-web": "workspace:*", - "@standardnotes/snjs": "^2.118.3", + "@standardnotes/snjs": "workspace:*", "@standardnotes/styles": "workspace:*", "@standardnotes/toast": "workspace:*", "@zip.js/zip.js": "^2.4.10", diff --git a/yarn.lock b/yarn.lock index 4388e33d0..9f5eb5ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -287,7 +287,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.1.0, @babel/core@npm:^7.1.6, @babel/core@npm:^7.11.1, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.16, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.10, @babel/core@npm:^7.13.13, @babel/core@npm:^7.13.14, @babel/core@npm:^7.13.8, @babel/core@npm:^7.14.0, @babel/core@npm:^7.14.6, @babel/core@npm:^7.15.5, @babel/core@npm:^7.16.0, @babel/core@npm:^7.17.10, @babel/core@npm:^7.17.9, @babel/core@npm:^7.18.2, @babel/core@npm:^7.18.5, @babel/core@npm:^7.18.6, @babel/core@npm:^7.7.0, @babel/core@npm:^7.7.2, @babel/core@npm:^7.7.7, @babel/core@npm:^7.8.0": +"@babel/core@npm:^7.1.0, @babel/core@npm:^7.1.6, @babel/core@npm:^7.11.1, @babel/core@npm:^7.11.6, @babel/core@npm:^7.12.10, @babel/core@npm:^7.12.16, @babel/core@npm:^7.12.3, @babel/core@npm:^7.13.10, @babel/core@npm:^7.13.13, @babel/core@npm:^7.13.14, @babel/core@npm:^7.13.8, @babel/core@npm:^7.14.0, @babel/core@npm:^7.14.6, @babel/core@npm:^7.15.5, @babel/core@npm:^7.16.0, @babel/core@npm:^7.17.10, @babel/core@npm:^7.17.5, @babel/core@npm:^7.17.9, @babel/core@npm:^7.18.2, @babel/core@npm:^7.18.5, @babel/core@npm:^7.18.6, @babel/core@npm:^7.7.0, @babel/core@npm:^7.7.2, @babel/core@npm:^7.7.7, @babel/core@npm:^7.8.0": version: 7.18.6 resolution: "@babel/core@npm:7.18.6" dependencies: @@ -4761,6 +4761,16 @@ __metadata: languageName: node linkType: hard +"@mrmlnc/readdir-enhanced@npm:^2.2.1": + version: 2.2.1 + resolution: "@mrmlnc/readdir-enhanced@npm:2.2.1" + dependencies: + call-me-maybe: ^1.0.1 + glob-to-regexp: ^0.3.0 + checksum: d3b82b29368821154ce8e10bef5ccdbfd070d3e9601643c99ea4607e56f3daeaa4e755dd6d2355da20762c695c1b0570543d9f84b48f70c211ec09c4aaada2e1 + languageName: node + linkType: hard + "@nanostores/preact@npm:^0.1.3": version: 0.1.3 resolution: "@nanostores/preact@npm:0.1.3" @@ -4806,6 +4816,13 @@ __metadata: languageName: node linkType: hard +"@nodelib/fs.stat@npm:^1.1.2": + version: 1.1.3 + resolution: "@nodelib/fs.stat@npm:1.1.3" + checksum: 318deab369b518a34778cdaa0054dd28a4381c0c78e40bbd20252f67d084b1d7bf9295fea4423de2c19ac8e1a34f120add9125f481b2a710f7068bcac7e3e305 + languageName: node + linkType: hard + "@nodelib/fs.walk@npm:^1.2.3": version: 1.2.8 resolution: "@nodelib/fs.walk@npm:1.2.8" @@ -6081,7 +6098,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^1.7.0": +"@sinonjs/commons@npm:^1.6.0, @sinonjs/commons@npm:^1.7.0, @sinonjs/commons@npm:^1.8.3": version: 1.8.3 resolution: "@sinonjs/commons@npm:1.8.3" dependencies: @@ -6090,6 +6107,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:>=5, @sinonjs/fake-timers@npm:^9.1.2": + version: 9.1.2 + resolution: "@sinonjs/fake-timers@npm:9.1.2" + dependencies: + "@sinonjs/commons": ^1.7.0 + checksum: 7d3aef54e17c1073101cb64d953157c19d62a40e261a30923fa1ee337b049c5f29cc47b1f0c477880f42b5659848ba9ab897607ac8ea4acd5c30ddcfac57fca6 + languageName: node + linkType: hard + "@sinonjs/fake-timers@npm:^8.0.1": version: 8.1.0 resolution: "@sinonjs/fake-timers@npm:8.1.0" @@ -6099,12 +6125,21 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^9.1.2": - version: 9.1.2 - resolution: "@sinonjs/fake-timers@npm:9.1.2" +"@sinonjs/samsam@npm:^6.1.1": + version: 6.1.1 + resolution: "@sinonjs/samsam@npm:6.1.1" dependencies: - "@sinonjs/commons": ^1.7.0 - checksum: 7d3aef54e17c1073101cb64d953157c19d62a40e261a30923fa1ee337b049c5f29cc47b1f0c477880f42b5659848ba9ab897607ac8ea4acd5c30ddcfac57fca6 + "@sinonjs/commons": ^1.6.0 + lodash.get: ^4.4.2 + type-detect: ^4.0.8 + checksum: a09b0914bf573f0da82bd03c64ba413df81a7c173818dc3f0a90c2652240ac835ef583f4d52f0b215e626633c91a4095c255e0669f6ead97241319f34f05e7fc + languageName: node + linkType: hard + +"@sinonjs/text-encoding@npm:^0.7.1": + version: 0.7.1 + resolution: "@sinonjs/text-encoding@npm:0.7.1" + checksum: 130de0bb568c5f8a611ec21d1a4e3f80ab0c5ec333010f49cfc1adc5cba6d8808699c8a587a46b0f0b016a1f4c1389bc96141e773e8460fcbb441875b2e91ba7 languageName: node linkType: hard @@ -6167,7 +6202,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/api@^1.1.19, @standardnotes/api@workspace:packages/api": +"@standardnotes/api@workspace:*, @standardnotes/api@workspace:packages/api": version: 0.0.0-use.local resolution: "@standardnotes/api@workspace:packages/api" dependencies: @@ -6333,7 +6368,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/common@npm:1.25.0": +"@standardnotes/common@npm:1.25.0, @standardnotes/common@npm:^1.25.0": version: 1.25.0 resolution: "@standardnotes/common@npm:1.25.0" dependencies: @@ -6466,14 +6501,16 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/domain-events@npm:^2.32.6": - version: 2.32.6 - resolution: "@standardnotes/domain-events@npm:2.32.6" +"@standardnotes/domain-events@npm:^2.39.0": + version: 2.39.0 + resolution: "@standardnotes/domain-events@npm:2.39.0" dependencies: - "@standardnotes/auth": ^3.19.4 + "@standardnotes/common": 1.25.0 "@standardnotes/features": ^1.47.0 - "@standardnotes/scheduler": ^1.1.2 - checksum: 1c5852e776618d1505a0fc8e9f0166bf1459c0c05db67223092f41659ef1e5a83156a2886d717f74e57fa776cc20299538641adf4277e3d93567a25ef636253a + "@standardnotes/predicates": 1.2.0 + "@standardnotes/security": 1.2.0 + reflect-metadata: ^0.1.13 + checksum: 7a37b281eff4e510d28a6bac94c9eb50be4a156704403875dce054d516f649296092755d65fddd251f8761fab93fefef1694018a44544a2393eed96861ecd4b1 languageName: node linkType: hard @@ -6525,7 +6562,7 @@ __metadata: languageName: node linkType: hard -"@standardnotes/encryption@^1.8.23, @standardnotes/encryption@workspace:*, @standardnotes/encryption@workspace:packages/encryption": +"@standardnotes/encryption@workspace:*, @standardnotes/encryption@workspace:packages/encryption": version: 0.0.0-use.local resolution: "@standardnotes/encryption@workspace:packages/encryption" dependencies: @@ -6602,7 +6639,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/files@^1.3.23, @standardnotes/files@workspace:*, @standardnotes/files@workspace:packages/files": +"@standardnotes/files@workspace:*, @standardnotes/files@workspace:packages/files": version: 0.0.0-use.local resolution: "@standardnotes/files@workspace:packages/files" dependencies: @@ -6917,7 +6954,7 @@ __metadata: "@standardnotes/react-native-textview": 1.1.0 "@standardnotes/react-native-utils": 1.0.1 "@standardnotes/sncrypto-common": "workspace:*" - "@standardnotes/snjs": ^2.118.3 + "@standardnotes/snjs": "workspace:*" "@standardnotes/stylekit": 5.29.3 "@standardnotes/utils": "workspace:*" "@standardnotes/web": "workspace:*" @@ -6994,7 +7031,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/models@^1.11.13, @standardnotes/models@workspace:*, @standardnotes/models@workspace:packages/models": +"@standardnotes/models@workspace:*, @standardnotes/models@workspace:packages/models": version: 0.0.0-use.local resolution: "@standardnotes/models@workspace:packages/models" dependencies: @@ -7013,6 +7050,16 @@ __metadata: languageName: unknown linkType: soft +"@standardnotes/predicates@npm:1.2.0": + version: 1.2.0 + resolution: "@standardnotes/predicates@npm:1.2.0" + dependencies: + "@standardnotes/common": 1.25.0 + reflect-metadata: ^0.1.13 + checksum: 779a3fcf93827fb39c9e126f84ef85dde403172247af90ab92672e4e53f6288dd981cca286b8352ca031ba3f093441dc30b4666dcfbb785b272354e54cab0d53 + languageName: node + linkType: hard + "@standardnotes/react-native-aes@npm:^1.4.3": version: 1.4.3 resolution: "@standardnotes/react-native-aes@npm:1.4.3" @@ -7050,7 +7097,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/responses@^1.6.39, @standardnotes/responses@^1.9.0, @standardnotes/responses@workspace:packages/responses": +"@standardnotes/responses@^1.9.0, @standardnotes/responses@workspace:*, @standardnotes/responses@workspace:packages/responses": version: 0.0.0-use.local resolution: "@standardnotes/responses@workspace:packages/responses" dependencies: @@ -7095,12 +7142,14 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/scheduler@npm:^1.1.2": - version: 1.1.2 - resolution: "@standardnotes/scheduler@npm:1.1.2" +"@standardnotes/security@npm:1.2.0, @standardnotes/security@npm:^1.2.0": + version: 1.2.0 + resolution: "@standardnotes/security@npm:1.2.0" dependencies: - "@standardnotes/common": ^1.23.1 - checksum: 68642a08741aeb4936e4a69e43e2b85e5cbfa3ce60006d292cee517d33093edaf35a6a7af61343d85ab3474d6e980a073a2779f6e0b1f11805ab06a407b15ce7 + "@standardnotes/common": 1.25.0 + jsonwebtoken: ^8.5.1 + reflect-metadata: ^0.1.13 + checksum: 3fb92e2be0c432230d11f3ba76e696f50dd3d885ce365fbb4d12e308b7b1d49d54fbca6d931f04334de8cdcdfcde381c01d1497c18a1c15204f8ec14d2b8825b languageName: node linkType: hard @@ -7115,18 +7164,7 @@ __metadata: languageName: node linkType: hard -"@standardnotes/security@npm:^1.2.0": - version: 1.2.0 - resolution: "@standardnotes/security@npm:1.2.0" - dependencies: - "@standardnotes/common": 1.25.0 - jsonwebtoken: ^8.5.1 - reflect-metadata: ^0.1.13 - checksum: 3fb92e2be0c432230d11f3ba76e696f50dd3d885ce365fbb4d12e308b7b1d49d54fbca6d931f04334de8cdcdfcde381c01d1497c18a1c15204f8ec14d2b8825b - languageName: node - linkType: hard - -"@standardnotes/services@^1.13.23, @standardnotes/services@workspace:*, @standardnotes/services@workspace:packages/services": +"@standardnotes/services@workspace:*, @standardnotes/services@workspace:packages/services": version: 0.0.0-use.local resolution: "@standardnotes/services@workspace:packages/services" dependencies: @@ -7146,10 +7184,12 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/settings@npm:^1.15.0": - version: 1.15.0 - resolution: "@standardnotes/settings@npm:1.15.0" - checksum: 4397d453a1a0cd5554012a5bf5ee2025c102ffc6d742841eecd2ab9cd46b0fbfe78da61b401451646274887d92801cafcef4c283bbfc6074b4d152103e15f861 +"@standardnotes/settings@npm:^1.17.0": + version: 1.17.0 + resolution: "@standardnotes/settings@npm:1.17.0" + dependencies: + reflect-metadata: ^0.1.13 + checksum: b376015038d74cad46033ec4698ecd1f6b4c9cbb626e89eeae8c07fe43595708f80dfa6d8db551b8e6bf44d4803617974df6964df75164d690149c9054fd43bd languageName: node linkType: hard @@ -7190,7 +7230,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/sncrypto-common@^1.7.1, @standardnotes/sncrypto-common@^1.9.0, @standardnotes/sncrypto-common@workspace:*, @standardnotes/sncrypto-common@workspace:packages/sncrypto-common": +"@standardnotes/sncrypto-common@^1.7.1, @standardnotes/sncrypto-common@workspace:*, @standardnotes/sncrypto-common@workspace:packages/sncrypto-common": version: 0.0.0-use.local resolution: "@standardnotes/sncrypto-common@workspace:packages/sncrypto-common" dependencies: @@ -7230,27 +7270,68 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/snjs@npm:^2.118.3, @standardnotes/snjs@npm:^2.41.1": - version: 2.118.3 - resolution: "@standardnotes/snjs@npm:2.118.3" +"@standardnotes/snjs@^2.118.3, @standardnotes/snjs@^2.41.1, @standardnotes/snjs@workspace:*, @standardnotes/snjs@workspace:packages/snjs": + version: 0.0.0-use.local + resolution: "@standardnotes/snjs@workspace:packages/snjs" dependencies: - "@standardnotes/api": ^1.1.19 - "@standardnotes/auth": ^3.19.4 - "@standardnotes/common": ^1.23.1 - "@standardnotes/domain-events": ^2.32.6 - "@standardnotes/encryption": ^1.8.23 - "@standardnotes/features": ^1.47.0 - "@standardnotes/files": ^1.3.23 - "@standardnotes/models": ^1.11.13 - "@standardnotes/responses": ^1.6.39 - "@standardnotes/services": ^1.13.23 - "@standardnotes/settings": ^1.15.0 - "@standardnotes/sncrypto-common": ^1.9.0 - "@standardnotes/utils": ^1.6.12 + "@babel/core": ^7.17.5 + "@babel/preset-env": ^7.16.11 + "@standardnotes/api": "workspace:*" + "@standardnotes/common": ^1.25.0 + "@standardnotes/domain-events": ^2.39.0 + "@standardnotes/encryption": "workspace:*" + "@standardnotes/features": "workspace:*" + "@standardnotes/files": "workspace:*" + "@standardnotes/models": "workspace:*" + "@standardnotes/responses": "workspace:*" + "@standardnotes/security": ^1.2.0 + "@standardnotes/services": "workspace:*" + "@standardnotes/settings": ^1.17.0 + "@standardnotes/sncrypto-common": "workspace:*" + "@standardnotes/sncrypto-web": "workspace:*" + "@standardnotes/utils": "workspace:*" + "@types/crypto-js": ^4.1.1 + "@types/jest": ^28.1.4 + "@types/jsdom": ^16.2.14 + "@types/libsodium-wrappers": ^0.7.9 + "@types/lodash": ^4.14.179 + "@types/semver": ^7.3.10 + "@typescript-eslint/eslint-plugin": ^5.30.0 + babel-jest: ^28.1.2 + babel-loader: ^8.2.3 + chai: ^4.3.6 + chai-as-promised: ^7.1.1 + chai-subset: ^1.6.0 + circular-dependency-plugin: ^5.2.2 + crypto-js: ^4.1.1 + docdash: ^1.2.0 + dom-storage: ^2.1.0 + eslint-plugin-prettier: ^4.2.1 + exports-loader: ^3.1.0 + jest: ^28.1.2 + jest-environment-jsdom: ^28.1.2 + jsdom: ^19.0.0 + libsodium-wrappers: ^0.7.9 + lodash: ^4.17.21 + mocha: ^9.2.1 + mocha-headless-chrome: ^4.0.0 + nock: ^13.2.4 + otplib: ^12.0.1 + reflect-metadata: ^0.1.13 + regenerator-runtime: ^0.13.9 + script-loader: ^0.7.2 semver: ^7.3.7 - checksum: 96219fce6c2ca00b3dc91259b26a96ff1f87740e724a3c3970682f4592babe652ae3373a01be9cfc69dbe7a7e86f01601e9df65a47a8d543eecfa33896cc6d41 - languageName: node - linkType: hard + sinon: ^13.0.1 + ts-jest: ^28.0.5 + ts-loader: ^9.2.6 + ts-node: ^10.8.1 + tscpaths: 0.0.9 + uuid: ^8.3.2 + webpack: ^5.69.1 + webpack-cli: ^4.9.2 + webpack-merge: ^5.8.0 + languageName: unknown + linkType: soft "@standardnotes/solarized-dark-theme@workspace:packages/components/src/Packages/Themes/org.standardnotes.theme-solarized-dark": version: 0.0.0-use.local @@ -7367,7 +7448,7 @@ __metadata: languageName: unknown linkType: soft -"@standardnotes/utils@^1.6.12, @standardnotes/utils@workspace:*, @standardnotes/utils@workspace:packages/utils": +"@standardnotes/utils@workspace:*, @standardnotes/utils@workspace:packages/utils": version: 0.0.0-use.local resolution: "@standardnotes/utils@workspace:packages/utils" dependencies: @@ -7419,7 +7500,7 @@ __metadata: "@standardnotes/icons": "workspace:*" "@standardnotes/services": "workspace:*" "@standardnotes/sncrypto-web": "workspace:*" - "@standardnotes/snjs": ^2.118.3 + "@standardnotes/snjs": "workspace:*" "@standardnotes/styles": "workspace:*" "@standardnotes/toast": "workspace:*" "@types/jest": ^27.4.1 @@ -8044,6 +8125,13 @@ __metadata: languageName: node linkType: hard +"@types/crypto-js@npm:^4.1.1": + version: 4.1.1 + resolution: "@types/crypto-js@npm:4.1.1" + checksum: ea3d6a67b69f88baeb6af96004395903d2367a41bd5cd86306da23a44dd96589749495da50974a9b01bb5163c500764c8a33706831eade036bddae016417e3ea + languageName: node + linkType: hard + "@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6": version: 4.1.7 resolution: "@types/debug@npm:4.1.7" @@ -8316,7 +8404,7 @@ __metadata: languageName: node linkType: hard -"@types/jsdom@npm:^16.2.14": +"@types/jsdom@npm:^16.2.14, @types/jsdom@npm:^16.2.4": version: 16.2.14 resolution: "@types/jsdom@npm:16.2.14" dependencies: @@ -8928,7 +9016,7 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.6": +"@types/semver@npm:^7.3.10, @types/semver@npm:^7.3.6": version: 7.3.10 resolution: "@types/semver@npm:7.3.10" checksum: 7047c2822b1759b2b950f39cfcf261f2b9dca47b4b55bdebba0905a8553631f1531eb0f59264ffe4834d1198c8331c8e0010a4cd742f4e0b60abbf399d134364 @@ -9154,7 +9242,7 @@ __metadata: languageName: node linkType: hard -"@types/yauzl@npm:^2.10.0": +"@types/yauzl@npm:^2.10.0, @types/yauzl@npm:^2.9.1": version: 2.10.0 resolution: "@types/yauzl@npm:2.10.0" dependencies: @@ -9308,6 +9396,13 @@ __metadata: languageName: node linkType: hard +"@ungap/promise-all-settled@npm:1.1.2": + version: 1.1.2 + resolution: "@ungap/promise-all-settled@npm:1.1.2" + checksum: 08d37fdfa23a6fe8139f1305313562ebad973f3fac01bcce2773b2bda5bcb0146dfdcf3cb6a722cf0a5f2ca0bc56a827eac8f1e7b3beddc548f654addf1fc34c + languageName: node + linkType: hard + "@webassemblyjs/ast@npm:1.11.1": version: 1.11.1 resolution: "@webassemblyjs/ast@npm:1.11.1" @@ -10055,6 +10150,13 @@ __metadata: languageName: node linkType: hard +"ansi-colors@npm:4.1.1": + version: 4.1.1 + resolution: "ansi-colors@npm:4.1.1" + checksum: 138d04a51076cb085da0a7e2d000c5c0bb09f6e772ed5c65c53cb118d37f6c5f1637506d7155fb5f330f0abcf6f12fa2e489ac3f8cdab9da393bf1bb4f9a32b0 + languageName: node + linkType: hard + "ansi-colors@npm:^3.0.0": version: 3.2.4 resolution: "ansi-colors@npm:3.2.4" @@ -10330,6 +10432,18 @@ __metadata: languageName: node linkType: hard +"args@npm:^5.0.1": + version: 5.0.3 + resolution: "args@npm:5.0.3" + dependencies: + camelcase: 5.0.0 + chalk: 2.4.2 + leven: 2.1.0 + mri: 1.1.4 + checksum: ac39e656090f9364d7a2a42216a572dfe36d3e4d16d87ca4c1c9552a1c325dc222b642124cb96cdeeafb46662922910191f5aa12142cc4ca117b6d85454c8423 + languageName: node + linkType: hard + "aria-query@npm:^4.2.2": version: 4.2.2 resolution: "aria-query@npm:4.2.2" @@ -10460,7 +10574,7 @@ __metadata: languageName: node linkType: hard -"array-union@npm:^1.0.1": +"array-union@npm:^1.0.1, array-union@npm:^1.0.2": version: 1.0.2 resolution: "array-union@npm:1.0.2" dependencies: @@ -11918,6 +12032,13 @@ __metadata: languageName: node linkType: hard +"browser-stdout@npm:1.3.1": + version: 1.3.1 + resolution: "browser-stdout@npm:1.3.1" + checksum: b717b19b25952dd6af483e368f9bcd6b14b87740c3d226c2977a65e84666ffd67000bddea7d911f111a9b6ddc822b234de42d52ab6507bce4119a4cc003ef7b3 + languageName: node + linkType: hard + "browserify-aes@npm:^1.0.0, browserify-aes@npm:^1.0.4": version: 1.2.0 resolution: "browserify-aes@npm:1.2.0" @@ -12215,7 +12336,7 @@ __metadata: languageName: node linkType: hard -"buffer@npm:^5.1.0, buffer@npm:^5.4.3, buffer@npm:^5.5.0": +"buffer@npm:^5.1.0, buffer@npm:^5.2.1, buffer@npm:^5.4.3, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" dependencies: @@ -12538,6 +12659,13 @@ __metadata: languageName: node linkType: hard +"call-me-maybe@npm:^1.0.1": + version: 1.0.1 + resolution: "call-me-maybe@npm:1.0.1" + checksum: d19e9d6ac2c6a83fb1215718b64c5e233f688ebebb603bdfe4af59cde952df1f2b648530fab555bf290ea910d69d7d9665ebc916e871e0e194f47c2e48e4886b + languageName: node + linkType: hard + "caller-callsite@npm:^2.0.0": version: 2.0.0 resolution: "caller-callsite@npm:2.0.0" @@ -12605,6 +12733,13 @@ __metadata: languageName: node linkType: hard +"camelcase@npm:5.0.0": + version: 5.0.0 + resolution: "camelcase@npm:5.0.0" + checksum: 8bfe920e0472d79d34f0279da1391f155bcce7fc74c99b49dafae4f787396040a34f4023da837ab0b4372e63224b460f9524b495906863c38876faea9da53705 + languageName: node + linkType: hard + "camelcase@npm:^4.0.0": version: 4.1.0 resolution: "camelcase@npm:4.1.0" @@ -12712,6 +12847,24 @@ __metadata: languageName: node linkType: hard +"chai-as-promised@npm:^7.1.1": + version: 7.1.1 + resolution: "chai-as-promised@npm:7.1.1" + dependencies: + check-error: ^1.0.2 + peerDependencies: + chai: ">= 2.1.2 < 5" + checksum: 7262868a5b51a12af4e432838ddf97a893109266a505808e1868ba63a12de7ee1166e9d43b5c501a190c377c1b11ecb9ff8e093c89f097ad96c397e8ec0f8d6a + languageName: node + linkType: hard + +"chai-subset@npm:^1.6.0": + version: 1.6.0 + resolution: "chai-subset@npm:1.6.0" + checksum: c85a64b42dcb031a987c0a0fa85f21a7873a01d1e519f29b72311aade30a2626be9b48effad765fda560904c491e89b4cb4a60565e63057963207a6bcb60d285 + languageName: node + linkType: hard + "chai@npm:^4.3.6": version: 4.3.6 resolution: "chai@npm:4.3.6" @@ -12902,7 +13055,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.1, chokidar@npm:^3.5.3": +"chokidar@npm:3.5.3, chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.1, chokidar@npm:^3.5.3": version: 3.5.3 resolution: "chokidar@npm:3.5.3" dependencies: @@ -14332,7 +14485,7 @@ __metadata: languageName: node linkType: hard -"cross-fetch@npm:^3.1.5": +"cross-fetch@npm:3.1.5, cross-fetch@npm:^3.1.5": version: 3.1.5 resolution: "cross-fetch@npm:3.1.5" dependencies: @@ -14414,6 +14567,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.1.1": + version: 4.1.1 + resolution: "crypto-js@npm:4.1.1" + checksum: b3747c12ee3a7632fab3b3e171ea50f78b182545f0714f6d3e7e2858385f0f4101a15f2517e033802ce9d12ba50a391575ff4638c9de3dd9b2c4bc47768d5425 + languageName: node + linkType: hard + "crypto-random-string@npm:^1.0.0": version: 1.0.0 resolution: "crypto-random-string@npm:1.0.0" @@ -15723,7 +15883,7 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^3.0.2": +"data-urls@npm:^3.0.1, data-urls@npm:^3.0.2": version: 3.0.2 resolution: "data-urls@npm:3.0.2" dependencies: @@ -15766,7 +15926,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:4.3.4, debug@npm:^4.0.0, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -15778,6 +15938,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:4.3.3": + version: 4.3.3 + resolution: "debug@npm:4.3.3" + dependencies: + ms: 2.1.2 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 14472d56fe4a94dbcfaa6dbed2dd3849f1d72ba78104a1a328047bb564643ca49df0224c3a17fa63533fd11dd3d4c8636cd861191232a2c6735af00cc2d4de16 + languageName: node + linkType: hard + "debug@npm:^3.1.0, debug@npm:^3.1.1, debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" @@ -16293,6 +16465,13 @@ __metadata: languageName: node linkType: hard +"devtools-protocol@npm:0.0.981744": + version: 0.0.981744 + resolution: "devtools-protocol@npm:0.0.981744" + checksum: 609901bff551f5fbe98e272bdda7269de527e28fab138de371ffd7e9a06fa68ca2eec2e961bd1cd6fd98223ccee562c55b7ce3235d4e37cc8d85d6681bc45593 + languageName: node + linkType: hard + "didyoumean@npm:^1.2.2": version: 1.2.2 resolution: "didyoumean@npm:1.2.2" @@ -16314,6 +16493,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:5.0.0": + version: 5.0.0 + resolution: "diff@npm:5.0.0" + checksum: f19fe29284b633afdb2725c2a8bb7d25761ea54d321d8e67987ac851c5294be4afeab532bd84531e02583a3fe7f4014aa314a3eda84f5590e7a9e6b371ef3b46 + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -16353,7 +16539,7 @@ __metadata: languageName: node linkType: hard -"dir-glob@npm:^2.0.0": +"dir-glob@npm:^2.0.0, dir-glob@npm:^2.2.2": version: 2.2.2 resolution: "dir-glob@npm:2.2.2" dependencies: @@ -16460,6 +16646,13 @@ __metadata: languageName: node linkType: hard +"docdash@npm:^1.2.0": + version: 1.2.0 + resolution: "docdash@npm:1.2.0" + checksum: 0c1ca8a2d8aaa7dfd58ac1e74e369e8280d602872f8eadb6edd545ee0e16728ee639a33190781162768a6ef2103a1f3564f47dd743f56c161b4fff3a807c792e + languageName: node + linkType: hard + "doctrine@npm:^2.1.0": version: 2.1.0 resolution: "doctrine@npm:2.1.0" @@ -16547,6 +16740,13 @@ __metadata: languageName: node linkType: hard +"dom-storage@npm:^2.1.0": + version: 2.1.0 + resolution: "dom-storage@npm:2.1.0" + checksum: b17f9f9a1325b720b5cec9953f82624f57f12ae92bf9c03c413e668d9e1713cdc414058d52ee63e258c0a8673d7e9f6031d99d81b3f334b8c4c192e286990fd5 + languageName: node + linkType: hard + "domain-browser@npm:^1.1.1, domain-browser@npm:^1.2.0": version: 1.2.0 resolution: "domain-browser@npm:1.2.0" @@ -17315,6 +17515,13 @@ __metadata: languageName: node linkType: hard +"escape-string-regexp@npm:4.0.0, escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 + languageName: node + linkType: hard + "escape-string-regexp@npm:5.0.0, escape-string-regexp@npm:^5.0.0": version: 5.0.0 resolution: "escape-string-regexp@npm:5.0.0" @@ -17329,13 +17536,6 @@ __metadata: languageName: node linkType: hard -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 98b48897d93060f2322108bf29db0feba7dd774be96cd069458d1453347b25ce8682ecc39859d4bca2203cc0ab19c237bcc71755eff49a0f8d90beadeeba5cc5 - languageName: node - linkType: hard - "escodegen@npm:^2.0.0": version: 2.0.0 resolution: "escodegen@npm:2.0.0" @@ -18167,6 +18367,17 @@ __metadata: languageName: node linkType: hard +"exports-loader@npm:^3.1.0": + version: 3.1.0 + resolution: "exports-loader@npm:3.1.0" + dependencies: + source-map: ^0.6.1 + peerDependencies: + webpack: ^5.0.0 + checksum: 1bf210433b53cd33e603acea2a8ba9ff37e2fee1e25662374ea66c21bd0b8ea8780b3c67cc83dd8fa284892c51a75da4a88f05a0e7931532368c0e366d9a84da + languageName: node + linkType: hard + "express@npm:^4.17.1, express@npm:^4.17.3": version: 4.18.1 resolution: "express@npm:4.18.1" @@ -18282,6 +18493,23 @@ __metadata: languageName: node linkType: hard +"extract-zip@npm:2.0.1": + version: 2.0.1 + resolution: "extract-zip@npm:2.0.1" + dependencies: + "@types/yauzl": ^2.9.1 + debug: ^4.1.1 + get-stream: ^5.1.0 + yauzl: ^2.10.0 + dependenciesMeta: + "@types/yauzl": + optional: true + bin: + extract-zip: cli.js + checksum: 8cbda9debdd6d6980819cc69734d874ddd71051c9fe5bde1ef307ebcedfe949ba57b004894b585f758b7c9eeeea0e3d87f2dda89b7d25320459c2c9643ebb635 + languageName: node + linkType: hard + "extract-zip@npm:^1.0.3": version: 1.7.0 resolution: "extract-zip@npm:1.7.0" @@ -18331,6 +18559,20 @@ __metadata: languageName: node linkType: hard +"fast-glob@npm:^2.2.6": + version: 2.2.7 + resolution: "fast-glob@npm:2.2.7" + dependencies: + "@mrmlnc/readdir-enhanced": ^2.2.1 + "@nodelib/fs.stat": ^1.1.2 + glob-parent: ^3.1.0 + is-glob: ^4.0.0 + merge2: ^1.2.3 + micromatch: ^3.1.10 + checksum: 304ccff1d437fcc44ae0168b0c3899054b92e0fd6af6ad7c3ccc82ab4ddd210b99c7c739d60ee3686da2aa165cd1a31810b31fd91f7c2a575d297342a9fc0534 + languageName: node + linkType: hard + "fast-glob@npm:^3.1.1, fast-glob@npm:^3.2.11, fast-glob@npm:^3.2.4, fast-glob@npm:^3.2.7, fast-glob@npm:^3.2.9": version: 3.2.11 resolution: "fast-glob@npm:3.2.11" @@ -19665,6 +19907,13 @@ __metadata: languageName: node linkType: hard +"glob-to-regexp@npm:^0.3.0": + version: 0.3.0 + resolution: "glob-to-regexp@npm:0.3.0" + checksum: d34b3219d860042d508c4893b67617cd16e2668827e445ff39cff9f72ef70361d3dc24f429e003cdfb6607c75c9664b8eadc41d2eeb95690af0b0d3113c1b23b + languageName: node + linkType: hard + "glob-to-regexp@npm:^0.4.1": version: 0.4.1 resolution: "glob-to-regexp@npm:0.4.1" @@ -19672,6 +19921,20 @@ __metadata: languageName: node linkType: hard +"glob@npm:7.2.0": + version: 7.2.0 + resolution: "glob@npm:7.2.0" + dependencies: + fs.realpath: ^1.0.0 + inflight: ^1.0.4 + inherits: 2 + minimatch: ^3.0.4 + once: ^1.3.0 + path-is-absolute: ^1.0.0 + checksum: 78a8ea942331f08ed2e055cb5b9e40fe6f46f579d7fd3d694f3412fe5db23223d29b7fee1575440202e9a7ff9a72ab106a39fee39934c7bedafe5e5f8ae20134 + languageName: node + linkType: hard + "glob@npm:^6.0.1": version: 6.0.4 resolution: "glob@npm:6.0.4" @@ -19941,6 +20204,22 @@ __metadata: languageName: node linkType: hard +"globby@npm:^9.2.0": + version: 9.2.0 + resolution: "globby@npm:9.2.0" + dependencies: + "@types/glob": ^7.1.1 + array-union: ^1.0.2 + dir-glob: ^2.2.2 + fast-glob: ^2.2.6 + glob: ^7.1.3 + ignore: ^4.0.3 + pify: ^4.0.1 + slash: ^2.0.0 + checksum: 9b4cb70aa0b43bf89b18cf0e543695185e16d8dd99c17bdc6a1df0a9f88ff9dc8d2467aebace54c3842fc451a564882948c87a3b4fbdb1cacf3e05fd54b6ac5d + languageName: node + linkType: hard + "globule@npm:^1.0.0": version: 1.3.4 resolution: "globule@npm:1.3.4" @@ -20046,6 +20325,13 @@ __metadata: languageName: node linkType: hard +"growl@npm:1.10.5": + version: 1.10.5 + resolution: "growl@npm:1.10.5" + checksum: 4b86685de6831cebcbb19f93870bea624afee61124b0a20c49017013987cd129e73a8c4baeca295728f41d21265e1f859d25ef36731b142ca59c655fea94bb1a + languageName: node + linkType: hard + "grunt-babel@npm:^8.0.0": version: 8.0.0 resolution: "grunt-babel@npm:8.0.0" @@ -20600,7 +20886,7 @@ __metadata: languageName: node linkType: hard -"he@npm:^1.1.1, he@npm:^1.2.0": +"he@npm:1.2.0, he@npm:^1.1.1, he@npm:^1.2.0": version: 1.2.0 resolution: "he@npm:1.2.0" bin: @@ -21090,7 +21376,7 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": +"https-proxy-agent@npm:5.0.1, https-proxy-agent@npm:^5.0.0, https-proxy-agent@npm:^5.0.1": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" dependencies: @@ -21262,7 +21548,7 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^4.0.6": +"ignore@npm:^4.0.3, ignore@npm:^4.0.6": version: 4.0.6 resolution: "ignore@npm:4.0.6" checksum: 248f82e50a430906f9ee7f35e1158e3ec4c3971451dd9f99c9bc1548261b4db2b99709f60ac6c6cac9333494384176cc4cc9b07acbe42d52ac6a09cad734d800 @@ -22919,6 +23205,22 @@ __metadata: languageName: node linkType: hard +"jest-environment-jsdom@npm:^28.1.2": + version: 28.1.2 + resolution: "jest-environment-jsdom@npm:28.1.2" + dependencies: + "@jest/environment": ^28.1.2 + "@jest/fake-timers": ^28.1.2 + "@jest/types": ^28.1.1 + "@types/jsdom": ^16.2.4 + "@types/node": "*" + jest-mock: ^28.1.1 + jest-util: ^28.1.1 + jsdom: ^19.0.0 + checksum: 73388b5cde4ce4b49cdb36746211b46c416a75b070837faefd4c907fe5095b2a7b197f753e10ee110c4b8f43571ffc277b65b3ca48f01ec0fbc74525274a19fc + languageName: node + linkType: hard + "jest-environment-node@npm:^27.5.1": version: 27.5.1 resolution: "jest-environment-node@npm:27.5.1" @@ -23726,6 +24028,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:4.1.0, js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": + version: 4.1.0 + resolution: "js-yaml@npm:4.1.0" + dependencies: + argparse: ^2.0.1 + bin: + js-yaml: bin/js-yaml.js + checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a + languageName: node + linkType: hard + "js-yaml@npm:^3.13.1, js-yaml@npm:^3.14.1, js-yaml@npm:~3.14.0": version: 3.14.1 resolution: "js-yaml@npm:3.14.1" @@ -23738,17 +24051,6 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.0.0, js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" - dependencies: - argparse: ^2.0.1 - bin: - js-yaml: bin/js-yaml.js - checksum: c7830dfd456c3ef2c6e355cc5a92e6700ceafa1d14bba54497b34a99f0376cecbb3e9ac14d3e5849b426d5a5140709a66237a8c991c675431271c4ce5504151a - languageName: node - linkType: hard - "jsbn@npm:~0.1.0": version: 0.1.1 resolution: "jsbn@npm:0.1.1" @@ -23834,6 +24136,46 @@ __metadata: languageName: node linkType: hard +"jsdom@npm:^19.0.0": + version: 19.0.0 + resolution: "jsdom@npm:19.0.0" + dependencies: + abab: ^2.0.5 + acorn: ^8.5.0 + acorn-globals: ^6.0.0 + cssom: ^0.5.0 + cssstyle: ^2.3.0 + data-urls: ^3.0.1 + decimal.js: ^10.3.1 + domexception: ^4.0.0 + escodegen: ^2.0.0 + form-data: ^4.0.0 + html-encoding-sniffer: ^3.0.0 + http-proxy-agent: ^5.0.0 + https-proxy-agent: ^5.0.0 + is-potential-custom-element-name: ^1.0.1 + nwsapi: ^2.2.0 + parse5: 6.0.1 + saxes: ^5.0.1 + symbol-tree: ^3.2.4 + tough-cookie: ^4.0.0 + w3c-hr-time: ^1.0.2 + w3c-xmlserializer: ^3.0.0 + webidl-conversions: ^7.0.0 + whatwg-encoding: ^2.0.0 + whatwg-mimetype: ^3.0.0 + whatwg-url: ^10.0.0 + ws: ^8.2.3 + xml-name-validator: ^4.0.0 + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + checksum: 94b693bf4a394097dd96705550bb7b6cd3c8db3c5414e6e9c92a0995ed8b61067597da2f37fca6bed4b5a2f1ef33960ee759522156dccd0b306311988ea87cfb + languageName: node + linkType: hard + "jsdom@npm:^20.0.0": version: 20.0.0 resolution: "jsdom@npm:20.0.0" @@ -24130,6 +24472,13 @@ __metadata: languageName: node linkType: hard +"just-extend@npm:^4.0.2": + version: 4.2.1 + resolution: "just-extend@npm:4.2.1" + checksum: ff9fdede240fad313efeeeb68a660b942e5586d99c0058064c78884894a2690dc09bba44c994ad4e077e45d913fef01a9240c14a72c657b53687ac58de53b39c + languageName: node + linkType: hard + "jwa@npm:^1.4.1": version: 1.4.1 resolution: "jwa@npm:1.4.1" @@ -24381,6 +24730,13 @@ __metadata: languageName: node linkType: hard +"leven@npm:2.1.0": + version: 2.1.0 + resolution: "leven@npm:2.1.0" + checksum: f7b4a01b15c0ee2f92a04c0367ea025d10992b044df6f0d4ee1a845d4a488b343e99799e2f31212d72a2b1dea67124f57c1bb1b4561540df45190e44b5b8b394 + languageName: node + linkType: hard + "leven@npm:^3.1.0": version: 3.1.0 resolution: "leven@npm:3.1.0" @@ -24433,7 +24789,7 @@ __metadata: languageName: node linkType: hard -"libsodium-wrappers@npm:^0.7.10": +"libsodium-wrappers@npm:^0.7.10, libsodium-wrappers@npm:^0.7.9": version: 0.7.10 resolution: "libsodium-wrappers@npm:0.7.10" dependencies: @@ -24794,6 +25150,13 @@ __metadata: languageName: node linkType: hard +"lodash.get@npm:^4.4.2": + version: 4.4.2 + resolution: "lodash.get@npm:4.4.2" + checksum: e403047ddb03181c9d0e92df9556570e2b67e0f0a930fcbbbd779370972368f5568e914f913e93f3b08f6d492abc71e14d4e9b7a18916c31fa04bd2306efe545 + languageName: node + linkType: hard + "lodash.includes@npm:^4.3.0": version: 4.3.0 resolution: "lodash.includes@npm:4.3.0" @@ -24948,16 +25311,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^2.2.0": - version: 2.2.0 - resolution: "log-symbols@npm:2.2.0" - dependencies: - chalk: ^2.0.1 - checksum: 4c95e3b65f0352dbe91dc4989c10baf7a44e2ef5b0db7e6721e1476268e2b6f7090c3aa880d4f833a05c5c3ff18f4ec5215a09bd0099986d64a8186cfeb48ac8 - languageName: node - linkType: hard - -"log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": +"log-symbols@npm:4.1.0, log-symbols@npm:^4.0.0, log-symbols@npm:^4.1.0": version: 4.1.0 resolution: "log-symbols@npm:4.1.0" dependencies: @@ -24967,6 +25321,15 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^2.2.0": + version: 2.2.0 + resolution: "log-symbols@npm:2.2.0" + dependencies: + chalk: ^2.0.1 + checksum: 4c95e3b65f0352dbe91dc4989c10baf7a44e2ef5b0db7e6721e1476268e2b6f7090c3aa880d4f833a05c5c3ff18f4ec5215a09bd0099986d64a8186cfeb48ac8 + languageName: node + linkType: hard + "log-update@npm:^4.0.0": version: 4.0.0 resolution: "log-update@npm:4.0.0" @@ -25739,7 +26102,7 @@ __metadata: languageName: node linkType: hard -"merge2@npm:^1.3.0, merge2@npm:^1.4.1": +"merge2@npm:^1.2.3, merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" checksum: 7268db63ed5169466540b6fb947aec313200bcf6d40c5ab722c22e242f651994619bcd85601602972d3c85bd2cc45a358a4c61937e9f11a061919a1da569b0c2 @@ -26737,6 +27100,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:4.2.1": + version: 4.2.1 + resolution: "minimatch@npm:4.2.1" + dependencies: + brace-expansion: ^1.1.7 + checksum: 2b1514e3d0f29a549912f0db7ae7b82c5cab4a8f2dd0369f1c6451a325b3f12b2cf473c95873b6157bb8df183d6cf6db82ff03614b6adaaf1d7e055beccdfd01 + languageName: node + linkType: hard + "minimatch@npm:^5.0.1, minimatch@npm:^5.1.0": version: 5.1.0 resolution: "minimatch@npm:5.1.0" @@ -26945,6 +27317,54 @@ __metadata: languageName: node linkType: hard +"mocha-headless-chrome@npm:^4.0.0": + version: 4.0.0 + resolution: "mocha-headless-chrome@npm:4.0.0" + dependencies: + args: ^5.0.1 + mkdirp: ^1.0.4 + puppeteer: ^13.1.3 + bin: + mocha-headless-chrome: bin/start + checksum: ab802a342d5f340bb7c264c9802015474325acd285420bdc573ad7052b2550d1c68647970d9f0b44914831ddd6485881acf1132c205f485cf177e8a632f746c1 + languageName: node + linkType: hard + +"mocha@npm:^9.2.1": + version: 9.2.2 + resolution: "mocha@npm:9.2.2" + dependencies: + "@ungap/promise-all-settled": 1.1.2 + ansi-colors: 4.1.1 + browser-stdout: 1.3.1 + chokidar: 3.5.3 + debug: 4.3.3 + diff: 5.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 7.2.0 + growl: 1.10.5 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 4.2.1 + ms: 2.1.3 + nanoid: 3.3.1 + serialize-javascript: 6.0.0 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + which: 2.0.2 + workerpool: 6.2.0 + yargs: 16.2.0 + yargs-parser: 20.2.4 + yargs-unparser: 2.0.0 + bin: + _mocha: bin/_mocha + mocha: bin/mocha + checksum: 4d5ca4ce33fc66627e63acdf09a634e2358c9a00f61de7788b1091b6aad430da04f97f9ecb82d56dc034b623cb833b65576136fd010d77679c03fcea5bc1e12d + languageName: node + linkType: hard + "modify-values@npm:^1.0.0": version: 1.0.1 resolution: "modify-values@npm:1.0.1" @@ -27019,6 +27439,13 @@ __metadata: languageName: node linkType: hard +"mri@npm:1.1.4": + version: 1.1.4 + resolution: "mri@npm:1.1.4" + checksum: e65b9aed3b9e423ad4c11f529ab1b9280f65dce8fb476d0da236b5c570ad3322fbbcd2393180855f1474f8b0f982d76ad398766fbd47b8a5ab4069e325d0268e + languageName: node + linkType: hard + "mri@npm:^1.1.0": version: 1.2.0 resolution: "mri@npm:1.2.0" @@ -27125,6 +27552,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:3.3.1": + version: 3.3.1 + resolution: "nanoid@npm:3.3.1" + bin: + nanoid: bin/nanoid.cjs + checksum: 4ef0969e1bbe866fc223eb32276cbccb0961900bfe79104fa5abe34361979dead8d0e061410a5c03bc3d47455685adf32c09d6f27790f4a6898fb51f7df7ec86 + languageName: node + linkType: hard + "nanoid@npm:^3.1.23, nanoid@npm:^3.1.25, nanoid@npm:^3.3.1, nanoid@npm:^3.3.4": version: 3.3.4 resolution: "nanoid@npm:3.3.4" @@ -27204,6 +27640,19 @@ __metadata: languageName: node linkType: hard +"nise@npm:^5.1.1": + version: 5.1.1 + resolution: "nise@npm:5.1.1" + dependencies: + "@sinonjs/commons": ^1.8.3 + "@sinonjs/fake-timers": ">=5" + "@sinonjs/text-encoding": ^0.7.1 + just-extend: ^4.0.2 + path-to-regexp: ^1.7.0 + checksum: d8be29e84a014743c9a10f428fac86f294ac5f92bed1f606fe9b551e935f494d8e0ce1af8a12673c6014010ec7f771f2d48aa5c8e116f223eb4f40c5e1ab44b3 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -27221,6 +27670,18 @@ __metadata: languageName: node linkType: hard +"nock@npm:^13.2.4": + version: 13.2.8 + resolution: "nock@npm:13.2.8" + dependencies: + debug: ^4.1.0 + json-stringify-safe: ^5.0.1 + lodash: ^4.17.21 + propagate: ^2.0.0 + checksum: 656f696d3c1b6267b8ec366f5cc464306d2aa308ce9b41414e9992eea5b0b71e475a582945b936e16263961c3a0f9e1c388a3a36da53c1e300398a7826550f96 + languageName: node + linkType: hard + "node-abi@npm:^3.3.0": version: 3.22.0 resolution: "node-abi@npm:3.22.0" @@ -29171,6 +29632,15 @@ __metadata: languageName: node linkType: hard +"pkg-dir@npm:4.2.0, pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0": + version: 4.2.0 + resolution: "pkg-dir@npm:4.2.0" + dependencies: + find-up: ^4.0.0 + checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 + languageName: node + linkType: hard + "pkg-dir@npm:^3.0.0": version: 3.0.0 resolution: "pkg-dir@npm:3.0.0" @@ -29180,15 +29650,6 @@ __metadata: languageName: node linkType: hard -"pkg-dir@npm:^4.1.0, pkg-dir@npm:^4.2.0": - version: 4.2.0 - resolution: "pkg-dir@npm:4.2.0" - dependencies: - find-up: ^4.0.0 - checksum: 9863e3f35132bf99ae1636d31ff1e1e3501251d480336edb1c211133c8d58906bed80f154a1d723652df1fda91e01c7442c2eeaf9dc83157c7ae89087e43c8d6 - languageName: node - linkType: hard - "pkg-up@npm:3.1.0, pkg-up@npm:^3.1.0": version: 3.1.0 resolution: "pkg-up@npm:3.1.0" @@ -30830,7 +31291,7 @@ __metadata: languageName: node linkType: hard -"progress@npm:^2.0.0, progress@npm:^2.0.3": +"progress@npm:2.0.3, progress@npm:^2.0.0, progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" checksum: f67403fe7b34912148d9252cb7481266a354bd99ce82c835f79070643bb3c6583d10dbcfda4d41e04bbc1d8437e9af0fb1e1f2135727878f5308682a579429b7 @@ -30920,6 +31381,13 @@ __metadata: languageName: node linkType: hard +"propagate@npm:^2.0.0": + version: 2.0.1 + resolution: "propagate@npm:2.0.1" + checksum: c4febaee2be0979e82fb6b3727878fd122a98d64a7fa3c9d09b0576751b88514a9e9275b1b92e76b364d488f508e223bd7e1dcdc616be4cdda876072fbc2a96c + languageName: node + linkType: hard + "proper-lockfile@npm:^3.0.2": version: 3.2.0 resolution: "proper-lockfile@npm:3.2.0" @@ -31106,6 +31574,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 + languageName: node + linkType: hard + "proxyquire@npm:^2.1.3": version: 2.1.3 resolution: "proxyquire@npm:2.1.3" @@ -31222,6 +31697,26 @@ __metadata: languageName: node linkType: hard +"puppeteer@npm:^13.1.3": + version: 13.7.0 + resolution: "puppeteer@npm:13.7.0" + dependencies: + cross-fetch: 3.1.5 + debug: 4.3.4 + devtools-protocol: 0.0.981744 + extract-zip: 2.0.1 + https-proxy-agent: 5.0.1 + pkg-dir: 4.2.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + rimraf: 3.0.2 + tar-fs: 2.1.1 + unbzip2-stream: 1.4.3 + ws: 8.5.0 + checksum: 4062b3ac3330e70d58095c1e0c5a46cada39a3fc5608e0e0e204448a400aef4d785a3b21d9e7733b2b2dc8cf686a476abf016f05d2e9108b3753dd41d62b9d1c + languageName: node + linkType: hard + "pure-color@npm:^1.2.0": version: 1.3.0 resolution: "pure-color@npm:1.3.0" @@ -31437,6 +31932,13 @@ __metadata: languageName: node linkType: hard +"raw-loader@npm:~0.5.1": + version: 0.5.1 + resolution: "raw-loader@npm:0.5.1" + checksum: 8051ec0b804ee72fbeee9a0f6183df8c0f764ba23a78ed5229c981cfb3a560dabc7926670fb0125b1c3831998d053ae39d578f3fb46187538226ceedad8cf1ab + languageName: node + linkType: hard + "rc-config-loader@npm:^4.1.0": version: 4.1.0 resolution: "rc-config-loader@npm:4.1.0" @@ -33765,6 +34267,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:3.0.2, rimraf@npm:^3.0.0, rimraf@npm:^3.0.2, rimraf@npm:~3.0.2": + version: 3.0.2 + resolution: "rimraf@npm:3.0.2" + dependencies: + glob: ^7.1.3 + bin: + rimraf: bin.js + checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 + languageName: node + linkType: hard + "rimraf@npm:^2.5.2, rimraf@npm:^2.5.4, rimraf@npm:^2.6.3": version: 2.7.1 resolution: "rimraf@npm:2.7.1" @@ -33776,17 +34289,6 @@ __metadata: languageName: node linkType: hard -"rimraf@npm:^3.0.0, rimraf@npm:^3.0.2, rimraf@npm:~3.0.2": - version: 3.0.2 - resolution: "rimraf@npm:3.0.2" - dependencies: - glob: ^7.1.3 - bin: - rimraf: bin.js - checksum: 87f4164e396f0171b0a3386cc1877a817f572148ee13a7e113b238e48e8a9f2f31d009a92ec38a591ff1567d9662c6b67fd8818a2dbbaed74bc26a87a2a4a9a0 - languageName: node - linkType: hard - "rimraf@npm:~2.2.6": version: 2.2.8 resolution: "rimraf@npm:2.2.8" @@ -34378,6 +34880,15 @@ __metadata: languageName: node linkType: hard +"script-loader@npm:^0.7.2": + version: 0.7.2 + resolution: "script-loader@npm:0.7.2" + dependencies: + raw-loader: ~0.5.1 + checksum: e01b3fb3e58b5e777f418e26a6ec4f9ad633c94ce86eff51f39e341d846c524111643cc4770d89c7e3d0863ab5292485a69a0f7cb664d9bc20c9bbe4ca7035de + languageName: node + linkType: hard + "scroll-into-view-if-needed@npm:^2.2.28": version: 2.2.29 resolution: "scroll-into-view-if-needed@npm:2.2.29" @@ -34592,6 +35103,15 @@ __metadata: languageName: node linkType: hard +"serialize-javascript@npm:6.0.0, serialize-javascript@npm:^6.0.0": + version: 6.0.0 + resolution: "serialize-javascript@npm:6.0.0" + dependencies: + randombytes: ^2.1.0 + checksum: 56f90b562a1bdc92e55afb3e657c6397c01a902c588c0fe3d4c490efdcc97dcd2a3074ba12df9e94630f33a5ce5b76a74784a7041294628a6f4306e0ec84bf93 + languageName: node + linkType: hard + "serialize-javascript@npm:^2.1.0": version: 2.1.2 resolution: "serialize-javascript@npm:2.1.2" @@ -34617,15 +35137,6 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^6.0.0": - version: 6.0.0 - resolution: "serialize-javascript@npm:6.0.0" - dependencies: - randombytes: ^2.1.0 - checksum: 56f90b562a1bdc92e55afb3e657c6397c01a902c588c0fe3d4c490efdcc97dcd2a3074ba12df9e94630f33a5ce5b76a74784a7041294628a6f4306e0ec84bf93 - languageName: node - linkType: hard - "serve-handler@npm:^6.1.3": version: 6.1.3 resolution: "serve-handler@npm:6.1.3" @@ -34883,6 +35394,20 @@ __metadata: languageName: node linkType: hard +"sinon@npm:^13.0.1": + version: 13.0.2 + resolution: "sinon@npm:13.0.2" + dependencies: + "@sinonjs/commons": ^1.8.3 + "@sinonjs/fake-timers": ^9.1.2 + "@sinonjs/samsam": ^6.1.1 + diff: ^5.0.0 + nise: ^5.1.1 + supports-color: ^7.2.0 + checksum: 237f21c8c4a8b31574c71b1b9f4c0f74a63dde5c0e86bd116effa4ce63c52467bd45fb4034a8fa32656a7919d9b19fc7b108ca9e1e6e3144f3735da96dad2877 + languageName: node + linkType: hard + "sirv@npm:^1.0.7": version: 1.0.19 resolution: "sirv@npm:1.0.19" @@ -36011,6 +36536,13 @@ __metadata: languageName: node linkType: hard +"strip-json-comments@npm:3.1.1, strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": + version: 3.1.1 + resolution: "strip-json-comments@npm:3.1.1" + checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 + languageName: node + linkType: hard + "strip-json-comments@npm:^2.0.0, strip-json-comments@npm:~2.0.1": version: 2.0.1 resolution: "strip-json-comments@npm:2.0.1" @@ -36018,13 +36550,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^3.1.0, strip-json-comments@npm:^3.1.1": - version: 3.1.1 - resolution: "strip-json-comments@npm:3.1.1" - checksum: 492f73e27268f9b1c122733f28ecb0e7e8d8a531a6662efbd08e22cccb3f9475e90a1b82cab06a392f6afae6d2de636f977e231296400d0ec5304ba70f166443 - languageName: node - linkType: hard - "strip-outer@npm:^1.0.1": version: 1.0.1 resolution: "strip-outer@npm:1.0.1" @@ -36223,6 +36748,15 @@ __metadata: languageName: node linkType: hard +"supports-color@npm:8.1.1, supports-color@npm:^8.0.0": + version: 8.1.1 + resolution: "supports-color@npm:8.1.1" + dependencies: + has-flag: ^4.0.0 + checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 + languageName: node + linkType: hard + "supports-color@npm:^2.0.0": version: 2.0.0 resolution: "supports-color@npm:2.0.0" @@ -36248,7 +36782,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0": +"supports-color@npm:^7.0.0, supports-color@npm:^7.1.0, supports-color@npm:^7.2.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0" dependencies: @@ -36257,15 +36791,6 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8.0.0": - version: 8.1.1 - resolution: "supports-color@npm:8.1.1" - dependencies: - has-flag: ^4.0.0 - checksum: c052193a7e43c6cdc741eb7f378df605636e01ad434badf7324f17fb60c69a880d8d8fcdcb562cf94c2350e57b937d7425ab5b8326c67c2adc48f7c87c1db406 - languageName: node - linkType: hard - "supports-color@npm:^9.2.1": version: 9.2.2 resolution: "supports-color@npm:9.2.2" @@ -36448,7 +36973,7 @@ __metadata: languageName: node linkType: hard -"tar-fs@npm:^2.0.0": +"tar-fs@npm:2.1.1, tar-fs@npm:^2.0.0": version: 2.1.1 resolution: "tar-fs@npm:2.1.1" dependencies: @@ -37248,6 +37773,18 @@ __metadata: languageName: node linkType: hard +"tscpaths@npm:0.0.9": + version: 0.0.9 + resolution: "tscpaths@npm:0.0.9" + dependencies: + commander: ^2.20.0 + globby: ^9.2.0 + bin: + tscpaths: cjs/index.js + checksum: 384fc9f22ddd6903d555157507d249b37ed0c1716f04d56477e2ece16d01d411d8a13fd906a0f0b88d22e75bd043d6898e7c1acc4de2822a6ba78b0f83443b30 + languageName: node + linkType: hard + "tslib@npm:^1.8.1, tslib@npm:^1.9.0": version: 1.14.1 resolution: "tslib@npm:1.14.1" @@ -37328,7 +37865,7 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:4.0.8, type-detect@npm:^4.0.0, type-detect@npm:^4.0.5": +"type-detect@npm:4.0.8, type-detect@npm:^4.0.0, type-detect@npm:^4.0.5, type-detect@npm:^4.0.8": version: 4.0.8 resolution: "type-detect@npm:4.0.8" checksum: 62b5628bff67c0eb0b66afa371bd73e230399a8d2ad30d852716efcc4656a7516904570cd8631a49a3ce57c10225adf5d0cbdcb47f6b0255fe6557c453925a15 @@ -37567,6 +38104,16 @@ __metadata: languageName: node linkType: hard +"unbzip2-stream@npm:1.4.3": + version: 1.4.3 + resolution: "unbzip2-stream@npm:1.4.3" + dependencies: + buffer: ^5.2.1 + through: ^2.3.8 + checksum: 0e67c4a91f4fa0fc7b4045f8b914d3498c2fc2e8c39c359977708ec85ac6d6029840e97f508675fdbdf21fcb8d276ca502043406f3682b70f075e69aae626d1d + languageName: node + linkType: hard + "unc-path-regex@npm:^0.1.2": version: 0.1.2 resolution: "unc-path-regex@npm:0.1.2" @@ -39228,6 +39775,16 @@ __metadata: languageName: node linkType: hard +"whatwg-url@npm:^10.0.0": + version: 10.0.0 + resolution: "whatwg-url@npm:10.0.0" + dependencies: + tr46: ^3.0.0 + webidl-conversions: ^7.0.0 + checksum: a21ec309c5cc743fe9414509408bedf65eaf0fb5c17ac66baa08ef12fce16da4dd30ce90abefbd5a716408301c58a73666dabfd5042cf4242992eb98b954f861 + languageName: node + linkType: hard + "whatwg-url@npm:^11.0.0": version: 11.0.0 resolution: "whatwg-url@npm:11.0.0" @@ -39290,6 +39847,17 @@ __metadata: languageName: node linkType: hard +"which@npm:2.0.2, which@npm:^2.0.1, which@npm:^2.0.2, which@npm:~2.0.2": + version: 2.0.2 + resolution: "which@npm:2.0.2" + dependencies: + isexe: ^2.0.0 + bin: + node-which: ./bin/node-which + checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 + languageName: node + linkType: hard + "which@npm:^1.0.5, which@npm:^1.2.14, which@npm:^1.2.9, which@npm:^1.3.1": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -39301,17 +39869,6 @@ __metadata: languageName: node linkType: hard -"which@npm:^2.0.1, which@npm:^2.0.2, which@npm:~2.0.2": - version: 2.0.2 - resolution: "which@npm:2.0.2" - dependencies: - isexe: ^2.0.0 - bin: - node-which: ./bin/node-which - checksum: 1a5c563d3c1b52d5f893c8b61afe11abc3bab4afac492e8da5bde69d550de701cf9806235f20a47b5c8fa8a1d6a9135841de2596535e998027a54589000e66d1 - languageName: node - linkType: hard - "wide-align@npm:^1.1.0, wide-align@npm:^1.1.2, wide-align@npm:^1.1.5": version: 1.1.5 resolution: "wide-align@npm:1.1.5" @@ -39604,6 +40161,13 @@ __metadata: languageName: node linkType: hard +"workerpool@npm:6.2.0": + version: 6.2.0 + resolution: "workerpool@npm:6.2.0" + checksum: 3493b4f0ef979a23d2c1583d7ef85f62fc9463cc02f82829d3e7e663b517f8ae9707da0249b382e46ac58986deb0ca2232ee1081713741211bda9254b429c9bb + languageName: node + linkType: hard + "wrap-ansi@npm:^5.1.0": version: 5.1.0 resolution: "wrap-ansi@npm:5.1.0" @@ -39727,6 +40291,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:8.5.0": + version: 8.5.0 + resolution: "ws@npm:8.5.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 76f2f90e40344bf18fd544194e7067812fb1372b2a37865678d8f12afe4b478ff2ebc0c7c0aff82cd5e6b66fc43d889eec0f1865c2365d8f7a66d92da7744a77 + languageName: node + linkType: hard + "ws@npm:^1.1.0, ws@npm:^1.1.5": version: 1.1.5 resolution: "ws@npm:1.1.5" @@ -39761,7 +40340,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.4.2, ws@npm:^8.8.0": +"ws@npm:^8.2.3, ws@npm:^8.4.2, ws@npm:^8.8.0": version: 8.8.0 resolution: "ws@npm:8.8.0" peerDependencies: @@ -39918,6 +40497,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:20.2.4": + version: 20.2.4 + resolution: "yargs-parser@npm:20.2.4" + checksum: d251998a374b2743a20271c2fd752b9fbef24eb881d53a3b99a7caa5e8227fcafd9abf1f345ac5de46435821be25ec12189a11030c12ee6481fef6863ed8b924 + languageName: node + linkType: hard + "yargs-parser@npm:20.x, yargs-parser@npm:^20.2.2, yargs-parser@npm:^20.2.3": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -39952,7 +40538,7 @@ __metadata: languageName: node linkType: hard -"yargs-unparser@npm:^2.0.0": +"yargs-unparser@npm:2.0.0, yargs-unparser@npm:^2.0.0": version: 2.0.0 resolution: "yargs-unparser@npm:2.0.0" dependencies: @@ -39964,6 +40550,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:16.2.0, yargs@npm:^16.0.3, yargs@npm:^16.1.1, yargs@npm:^16.2.0": + version: 16.2.0 + resolution: "yargs@npm:16.2.0" + dependencies: + cliui: ^7.0.2 + escalade: ^3.1.1 + get-caller-file: ^2.0.5 + require-directory: ^2.1.1 + string-width: ^4.2.0 + y18n: ^5.0.5 + yargs-parser: ^20.2.2 + checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 + languageName: node + linkType: hard + "yargs@npm:^13.3.2": version: 13.3.2 resolution: "yargs@npm:13.3.2" @@ -40001,21 +40602,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^16.0.3, yargs@npm:^16.1.1, yargs@npm:^16.2.0": - version: 16.2.0 - resolution: "yargs@npm:16.2.0" - dependencies: - cliui: ^7.0.2 - escalade: ^3.1.1 - get-caller-file: ^2.0.5 - require-directory: ^2.1.1 - string-width: ^4.2.0 - y18n: ^5.0.5 - yargs-parser: ^20.2.2 - checksum: b14afbb51e3251a204d81937c86a7e9d4bdbf9a2bcee38226c900d00f522969ab675703bee2a6f99f8e20103f608382936034e64d921b74df82b63c07c5e8f59 - languageName: node - linkType: hard - "yargs@npm:^17.0.0, yargs@npm:^17.0.1, yargs@npm:^17.2.1, yargs@npm:^17.3.1, yargs@npm:^17.5.1": version: 17.5.1 resolution: "yargs@npm:17.5.1"